[
  {
    "path": ".coveragerc",
    "content": "[run]\nsource = comfy_cli\nomit = tests/*\n\n[report]\nexclude_lines =\n    pragma: no cover\n    def __repr__\n    if self.debug:\n    if __name__ == .__main__.:\n    raise NotImplementedError\n    pass\n    except ImportError:\n    def parse_args\n    @abstractmethod\n\nignore_errors = True\n\n[html]\ndirectory = coverage_html_report\n\n[xml]\noutput = coverage.xml\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a bug report to help us improve.\ntitle: ''\nlabels: bug\nassignees: ''\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Nice to have**\n- [ ] Terminal output\n- [ ] Screenshots\n\n**Additional context**\nAdd any other context about the problem here."
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Submit a feature request for this repo.\ntitle: ''\nlabels: enhancement\nassignees: ''\n\n---\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/codecov.yml",
    "content": "comment:\n  layout: \"diff, files\"\n\ncoverage:\n  status:\n    project:\n      default:\n        threshold: 0.1%\n    patch: off\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: \"github-actions\"\n    directory: \"/.github/workflows\"\n    schedule:\n      interval: \"monthly\"\n    open-pull-requests-limit: 1\n    groups:\n      ci-dependencies:\n        patterns:\n          - \"*\"\n"
  },
  {
    "path": ".github/workflows/build-and-test.yml",
    "content": "name: \"Test CLI Tool on Multiple Platforms\"\non:\n  push:\n    branches:\n      - main\n    paths:\n      - \"comfy_cli/**\"\n      - \"tests/e2e/**\"\n      - \"!.github/**\"\n      - \"!.coveragerc\"\n      - \"!.gitignore\"\n  pull_request:\n    branches:\n      - main\n    paths:\n      - \"comfy_cli/**\"\n      - \"tests/e2e/**\"\n      - \"!.github/**\"\n      - \"!.coveragerc\"\n      - \"!.gitignore\"\n\npermissions:\n  contents: read\n\njobs:\n  test:\n    name: \"Run Tests on Multiple Platforms\"\n    runs-on: ${{ matrix.os }}\n    env:\n      PYTHONIOENCODING: \"utf8\"\n\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [ubuntu-latest, windows-latest, macos-latest]\n        python-version: [\"3.10\"]\n\n    steps:\n      - name: Check out code\n        uses: actions/checkout@v6\n\n      - name: Set up Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: ${{ matrix.python-version }}\n\n      - name: Install Dependencies\n        run: |\n          python -m pip install --upgrade pip\n          pip install pytest\n          pip install -e .\n\n      - name: Test CUDA auto-detection (skips if no GPU)\n        run: |\n          pytest tests/comfy_cli/test_cuda_detect_real.py -v\n\n      - name: Test e2e\n        env:\n          PYTHONPATH: ${{ github.workspace }}\n          TEST_E2E: true\n        run: |\n          pytest tests/e2e\n\n      - name: Test torch backend compilation\n        env:\n          TEST_TORCH_BACKEND: \"true\"\n        run: |\n          pytest tests/uv/test_torch_backend_compile.py -xvs\n"
  },
  {
    "path": ".github/workflows/publish_package.yml",
    "content": "name: Publish to PyPI\n\non:\n  release:\n    types: [ created ]\n\npermissions:\n  contents: read\n\njobs:\n  build-n-publish-pypi:\n    name: Build and publish Python distributions to PyPI\n    runs-on: ubuntu-latest\n    permissions:\n      id-token: write  # IMPORTANT: this permission is mandatory for trusted publishing\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Set up Python 3.12\n        uses: actions/setup-python@v6\n        with:\n          python-version: '3.12'\n\n      - name: Install build and tomlkit dependencies\n        run: python -m pip install --upgrade pip build tomlkit\n\n      - name: Extract version from tag\n        id: get_version\n        run: |\n          VERSION=\"${GITHUB_REF#refs/tags/}\"\n          VERSION=\"${VERSION#v}\"\n          echo \"VERSION=${VERSION}\" >> $GITHUB_ENV\n\n      - name: Update version in pyproject.toml\n        run: |\n          python -c \"\n          import tomlkit\n          with open('pyproject.toml', 'r') as f:\n              content = tomlkit.load(f)\n          content['project']['version'] = '${{ env.VERSION }}'\n          with open('pyproject.toml', 'w') as f:\n              tomlkit.dump(content, f)\n          \"\n\n      - name: Build distribution\n        run: python -m build --sdist --wheel --outdir dist/\n\n      # - name: Publish distribution to TestPyPI for Validation\n      #   uses: pypa/gh-action-pypi-publish@v1.8.14\n      #   with:\n      #     repository_url: https://test.pypi.org/legacy/\n\n      # - name: Clear pip cache\n      #   run: pip cache purge\n\n      # - name: Install Comfy CLI from Test Pypi and Test\n      #   run: |\n      #     for i in {1..3}; do\n      #       pip install -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple comfy-cli==${{env.VERSION}} && break || sleep 5\n      #     done\n      #     comfy --help\n\n      - name: Publish distribution to Official PyPI\n        uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0\n\n  test-pip-installation:\n    name: Test Comfy CLI Installation via pip\n    needs: build-n-publish-pypi  # This job runs after build-n-publish completes successfully\n    runs-on: ubuntu-latest\n    steps:\n      - name: Set up Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: '3.10'\n\n      - name: Extract version from tag\n        id: get_version\n        run: |\n          VERSION=\"${GITHUB_REF#refs/tags/}\"\n          VERSION=\"${VERSION#v}\"\n          echo \"VERSION=${VERSION}\" >> $GITHUB_ENV\n\n      - name: Install Comfy CLI via pip and Test\n        run: |\n          # PyPI's index can lag behind a successful upload by a minute or\n          # two, so retry before failing the job.\n          for i in 1 2 3 4 5 6 7 8; do\n            pip install --no-cache-dir \"comfy-cli==${VERSION}\" && exit 0\n            echo \"Attempt $i: package not yet available on PyPI, waiting 15s...\"\n            sleep 15\n          done\n          echo \"::error::Failed to install comfy-cli==${VERSION} after 8 attempts\"\n          exit 1\n\n      - name: Test Comfy CLI Help\n        run: comfy --help\n"
  },
  {
    "path": ".github/workflows/pytest.yml",
    "content": "name: Run pytest\n\non:\n  push:\n    branches:\n      - main\n  pull_request:\n    branches:\n      - main\n\npermissions:\n  contents: read\n  statuses: write\n  pull-requests: write\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v6\n\n      - name: Set up Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: '3.10'  # Follow the min version in pyproject.toml\n\n      - name: Install dependencies\n        run: |\n          python -m pip install --upgrade pip\n          pip install pytest pytest-cov\n          pip install -e .\n\n      - name: Run tests\n        env:\n          PYTHONPATH: ${{ github.workspace }}\n        run: |\n          pytest --cov=comfy_cli --cov-report=xml .\n\n      - name: Upload coverage to Codecov\n        uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0\n        with:\n          token: ${{ secrets.CODECOV_TOKEN }}\n          fail_ci_if_error: true\n          verbose: true\n"
  },
  {
    "path": ".github/workflows/ruff_check.yml",
    "content": "name: ruff_check\n\non:\n  push:\n    branches:\n      - main\n  pull_request:\n    branches:\n      - main\n\npermissions:\n  contents: read\n\njobs:\n  ruff_check:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - name: Set up Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: \"3.x\"\n      - name: install ruff\n        run: |\n          python -m pip install --upgrade pip\n          pip install ruff\n      - name: lint check and then format check with ruff\n        run: |\n          ruff check\n          ruff format --diff\n"
  },
  {
    "path": ".github/workflows/run-on-gpu.yml",
    "content": "name: \"Test CLI Tool on GPU runners\"\n\non:\n  push:\n    branches:\n      - main\n    paths:\n      - \"comfy_cli/**\"\n      - \"!comfy_cli/test_**\"\n      - \"!.github/**\"\n      - \"!tests/**\"\n      - \"!.coveragerc\"\n      - \"!.gitignore\"\n  pull_request:\n    branches:\n      - main\n    paths:\n      - \"comfy_cli/**\"\n      - \"!comfy_cli/test_**\"\n      - \"!.github/**\"\n      - \"!tests/**\"\n      - \"!.coveragerc\"\n      - \"!.gitignore\"\n\npermissions:\n  contents: read\n\njobs:\n  test-cli-gpu:\n    name: \"Run Tests on GPU Runners\"\n    runs-on:\n      group: gpu-runners\n      labels: ${{ matrix.os }}-x64-gpu #\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [linux]\n\n    steps:\n      - name: Check out code\n        uses: actions/checkout@v6\n\n      - name: Check Nvidia\n        run: |\n          nvidia-smi\n\n      - name: Set up Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: 3.12\n\n      - name: Check disk space\n        run: |\n          df -h\n\n      - name: Install Dependencies\n        run: |\n          python -m pip install --upgrade pip\n          pip install pytest\n          pip install -e .\n\n      - name: Test CUDA auto-detection (real hardware)\n        run: |\n          pytest tests/comfy_cli/test_cuda_detect_real.py -v\n\n      - name: Test e2e\n        id: test-e2e\n        env:\n          PYTHONPATH: ${{ github.workspace }}\n          TEST_E2E: true\n          TEST_E2E_COMFY_INSTALL_FLAGS: --nvidia\n          TEST_E2E_COMFY_LAUNCH_FLAGS_EXTRA: \"\"\n        run: |\n          pytest tests/e2e\n\n      - name: Retry test e2e but without gpu\n        if: ${{ failure() && steps.test-e2e.conclusion == 'failure' }}\n        env:\n          PYTHONPATH: ${{ github.workspace }}\n          TEST_E2E: true\n        run: |\n          pytest tests/e2e\n"
  },
  {
    "path": ".github/workflows/test-mac.yml",
    "content": "name: \"Mac Specific Commands\"\non:\n  pull_request:\n    branches:\n      - main\n    paths:\n      - comfy_cli/**\n\npermissions:\n  contents: read\n\njobs:\n  test:\n    runs-on: macos-latest\n    env:\n      PYTHONIOENCODING: \"utf8\"\n\n    steps:\n      - name: Check out code\n        uses: actions/checkout@v6\n\n      - name: Set up Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: 3.12\n\n      - name: Install Dependencies\n        run: |\n          python -m venv venv\n          source venv/bin/activate\n          python -m pip install --upgrade pip\n          pip install -e .\n          comfy --skip-prompt --workspace ./ComfyUI install --fast-deps --m-series --skip-manager\n          comfy --here launch -- --cpu --quick-test-for-ci\n"
  },
  {
    "path": ".github/workflows/test-windows.yml",
    "content": "name: \"Windows Specific Commands\"\non:\n  pull_request:\n    branches:\n      - main\n    paths:\n      - comfy_cli/**\n\npermissions:\n  contents: read\n\njobs:\n  test:\n    runs-on: windows-latest\n    env:\n      PYTHONIOENCODING: \"utf8\"\n\n    steps:\n      - name: Check out code\n        uses: actions/checkout@v6\n\n      - name: Set up Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: 3.12\n\n      - name: Install Dependencies\n        run: |\n          python -m venv venv\n          .\\venv\\Scripts\\Activate.ps1\n          Get-Command python\n          python -m pip install --upgrade pip\n          pip install pytest\n          pip install -e .\n          comfy --skip-prompt --workspace ./ComfyUI install --fast-deps --nvidia --cuda-version 12.6 --skip-manager\n          comfy --here launch -- --cpu --quick-test-for-ci\n"
  },
  {
    "path": ".gitignore",
    "content": "__pycache__/\n*.py[cod]\n\n#COMMON CONFIGs\n.DS_Store\n.src_port\n.webpack_watch.log\n*.swp\n*.swo\n.vscode/settings.json\n.idea/\n.vscode/\n*.code-workspace\n.history\n\n# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\npip-wheel-metadata/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\n\n# PyInstaller\n# Usually these files are written by a python script from a template\n# before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# temporary files created by linting, tests, etc\n.pytest_cache/\n.ruff_cache/\ntests/temp/\n\nvenv/\n\nbisect_state.json\npython*\ncpython*\nrequirements.compiled\noverride.txt\n.coverage\ncoverage.xml\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "repos:\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v5.0.0\n    hooks:\n    -   id: check-yaml\n        exclude: ^tests/.*$\n    -   id: check-toml\n        exclude: ^tests/.*$\n    -   id: end-of-file-fixer\n        exclude: >-\n          (^.*\\.(json|txt)$)|(^tests/.*\\.toml$)|(.github/.*TEMPLATE)\n    -   id: trailing-whitespace\n        exclude: >-\n          (^.*\\.(json|txt)$)\n\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: v0.12.4\n    hooks:\n      # Run the linter.\n      - id: ruff\n        args: [ --fix ]\n      # Run the formatter.\n      - id: ruff-format\n\n  - repo: https://github.com/tox-dev/pyproject-fmt\n    rev: v2.6.0\n    hooks:\n      - id: pyproject-fmt\n        exclude: ^tests/.*$\n\n  - repo: https://github.com/astral-sh/uv-pre-commit\n    rev: 0.8.5\n    hooks:\n      - id: uv-lock\n      - id: uv-export\n        args: [ \"--output-file=pylock.toml\" ]\n      - id: uv-sync\n        args: [ \"--all-extras\" ]\n"
  },
  {
    "path": ".pylintrc",
    "content": "# .pylintrc or pylintrc\n\n[MAIN]\nmax-line-length=120\n"
  },
  {
    "path": "DEV_README.md",
    "content": "# Development Guide\n\nThis guide provides an overview of how to develop in this repository.\n\n## General guide\n\n1. Clone the repo, create and activate a conda env. Minimum Python version is 3.9.\n\n2. Install the package to your conda env.\n\n`pip install -e .`\n\n3. Set ENVIRONMENT variable to DEV.\n\n`export ENVIRONMENT=dev`\n\n4. Check if the \"comfy\" package can run.\n\n`comfy --help`\n\n5. Install the pre-commit hook to ensure that your code won't need reformatting later.\n\n`pre-commit install`\n\n6. To save time during code review, it's recommended that you also manually run\n   the unit tests before submitting a pull request (see below).\n\n## Running the unit tests\n\n1. Install pytest into your conda env. You should preferably be using Python 3.9\n   in your conda env, since it's the version we are targeting for compatibility.\n\n`pip install pytest pytest-cov`\n\n2. Verify that all unit tests run successfully.\n\n`pytest --cov=comfy_cli --cov-report=xml .`\n\n## Debugging\n\nYou can add following config to your VSCode `launch.json` to launch debugger.\n\n```json\n{\n  \"name\": \"Python Debugger: Run\",\n  \"type\": \"debugpy\",\n  \"request\": \"launch\",\n  \"module\": \"comfy_cli.__main__\",\n  \"args\": [],\n  \"console\": \"integratedTerminal\"\n}\n```\n\n## Making changes to the code base\n\nThere is a potential need for you to reinstall the package. You can do this by\neither run `pip install -e .` again (which will reinstall), or manually\nuninstall `pip uninstall comfy-cli` and reinstall, or even cleaning your conda\nenv and reinstalling the package (`pip install -e .`)\n\n## Packaging custom nodes with `.comfyignore`\n\n`comfy node pack` and `comfy node publish` now read an optional `.comfyignore`\nfile in the project root. The syntax matches `.gitignore` (implemented with\n`PathSpec`'s `gitwildmatch` rules), so you can reuse familiar patterns to keep\ndevelopment-only artifacts out of your published archive.\n\n- Patterns are evaluated against paths relative to the directory you run the\n  command from (usually the repo root).\n- Files required by the pack command itself (e.g. `__init__.py`, `web/*`) are\n  still forced into the archive even if they match an ignore pattern.\n- If no `.comfyignore` is present the command falls back to the original\n  behavior and zips every git-tracked file.\n\nExample `.comfyignore`:\n\n```gitignore\ndocs/\nfrontend/\ntests/\n*.psd\n```\n\nCommit the file alongside your node so teammates and CI pipelines produce the\nsame trimmed package.\n\n## Adding a new command\n\n- Register it under `comfy_cli/cmdline.py`\n\nIf it's contains subcommand, create folder under comfy_cli/command/[new_command] and\nadd the following boilerplate\n\n`comfy_cli/command/[new_command]/__init__.py`\n\n```\nfrom .command import app\n```\n\n`comfy_cli/command/[new_command]command.py`\n\n```\nimport typer\n\napp = typer.Typer()\n\n@app.command()\ndef option_a(name: str):\n  \"\"\"Add a new custom node\"\"\"\n  print(f\"Adding a new custom node: {name}\")\n\n\n@app.command()\ndef remove(name: str):\n  \"\"\"Remove a custom node\"\"\"\n  print(f\"Removing a custom node: {name}\")\n\n```\n\n## Important notes\n\n- Use `typer` for all command args management\n- Use `rich` for all console output\n  - For progress reporting, use either [`rich.progress`](https://rich.readthedocs.io/en/stable/progress.html)\n\n## Develop comfy-cli and ComfyUI-Manager (cm-cli) together\n\nComfyUI-Manager is now installed as a pip package (via `manager_requirements.txt`\nin the ComfyUI root) rather than being git-cloned into `custom_nodes/`.\n\n### Making changes to both\n1. Fork your own branches of `comfy-cli` and `ComfyUI-Manager`, make changes.\n2. Live-install `comfy-cli`:\n   - `pip install -e /path/to/comfy-cli`\n3. Live-install your fork of `ComfyUI-Manager` in editable mode:\n   - `pip install -e /path/to/ComfyUI-Manager`\n4. This makes the `cm-cli` entry point available and points it at your local source.\n\n### Trying changes to both\n1. Install both packages in editable mode as described above.\n2. Go to a test dir and run:\n   - `comfy --here install`\n3. The `cm-cli` command will resolve to your locally installed editable package.\n\n### Debugging both simultaneously\n1. Follow instructions above to get working install with changes.\n2. Add breakpoints directly to code: `import ipdb; ipdb.set_trace()`\n3. Execute relevant `comfy-cli` command.\n\n\n## Running E2E tests\n\nE2E tests perform real `comfy install`, `comfy launch`, and `comfy node` operations.\nThey are **disabled by default** and must be explicitly enabled.\n\n```bash\nTEST_E2E=true pytest tests/e2e/\n```\n\nFor pre-release testing against alternate ComfyUI repositories (e.g. Manager v4):\n\n```bash\nTEST_E2E=true \\\nTEST_E2E_COMFY_URL=\"https://github.com/ltdrdata/ComfyUI.git@dr-bump-manager\" \\\npytest tests/e2e/ -v\n```\n\nSee [docs/TESTING-e2e.md](docs/TESTING-e2e.md) for the full guide including\nenvironment variables, test suite details, and scenario descriptions.\n\n## Contact\n\nIf you have any questions or need further assistance, please contact the project maintainer at [???](mailto:???@drip.art).\n\nHappy coding!\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\r\n                       Version 3, 29 June 2007\r\n\r\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\r\n Everyone is permitted to copy and distribute verbatim copies\r\n of this license document, but changing it is not allowed.\r\n\r\n                            Preamble\r\n\r\n  The GNU General Public License is a free, copyleft license for\r\nsoftware and other kinds of works.\r\n\r\n  The licenses for most software and other practical works are designed\r\nto take away your freedom to share and change the works.  By contrast,\r\nthe GNU General Public License is intended to guarantee your freedom to\r\nshare and change all versions of a program--to make sure it remains free\r\nsoftware for all its users.  We, the Free Software Foundation, use the\r\nGNU General Public License for most of our software; it applies also to\r\nany other work released this way by its authors.  You can apply it to\r\nyour programs, too.\r\n\r\n  When we speak of free software, we are referring to freedom, not\r\nprice.  Our General Public Licenses are designed to make sure that you\r\nhave the freedom to distribute copies of free software (and charge for\r\nthem if you wish), that you receive source code or can get it if you\r\nwant it, that you can change the software or use pieces of it in new\r\nfree programs, and that you know you can do these things.\r\n\r\n  To protect your rights, we need to prevent others from denying you\r\nthese rights or asking you to surrender the rights.  Therefore, you have\r\ncertain responsibilities if you distribute copies of the software, or if\r\nyou modify it: responsibilities to respect the freedom of others.\r\n\r\n  For example, if you distribute copies of such a program, whether\r\ngratis or for a fee, you must pass on to the recipients the same\r\nfreedoms that you received.  You must make sure that they, too, receive\r\nor can get the source code.  And you must show them these terms so they\r\nknow their rights.\r\n\r\n  Developers that use the GNU GPL protect your rights with two steps:\r\n(1) assert copyright on the software, and (2) offer you this License\r\ngiving you legal permission to copy, distribute and/or modify it.\r\n\r\n  For the developers' and authors' protection, the GPL clearly explains\r\nthat there is no warranty for this free software.  For both users' and\r\nauthors' sake, the GPL requires that modified versions be marked as\r\nchanged, so that their problems will not be attributed erroneously to\r\nauthors of previous versions.\r\n\r\n  Some devices are designed to deny users access to install or run\r\nmodified versions of the software inside them, although the manufacturer\r\ncan do so.  This is fundamentally incompatible with the aim of\r\nprotecting users' freedom to change the software.  The systematic\r\npattern of such abuse occurs in the area of products for individuals to\r\nuse, which is precisely where it is most unacceptable.  Therefore, we\r\nhave designed this version of the GPL to prohibit the practice for those\r\nproducts.  If such problems arise substantially in other domains, we\r\nstand ready to extend this provision to those domains in future versions\r\nof the GPL, as needed to protect the freedom of users.\r\n\r\n  Finally, every program is threatened constantly by software patents.\r\nStates should not allow patents to restrict development and use of\r\nsoftware on general-purpose computers, but in those that do, we wish to\r\navoid the special danger that patents applied to a free program could\r\nmake it effectively proprietary.  To prevent this, the GPL assures that\r\npatents cannot be used to render the program non-free.\r\n\r\n  The precise terms and conditions for copying, distribution and\r\nmodification follow.\r\n\r\n                       TERMS AND CONDITIONS\r\n\r\n  0. Definitions.\r\n\r\n  \"This License\" refers to version 3 of the GNU General Public License.\r\n\r\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\r\nworks, such as semiconductor masks.\r\n\r\n  \"The Program\" refers to any copyrightable work licensed under this\r\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\r\n\"recipients\" may be individuals or organizations.\r\n\r\n  To \"modify\" a work means to copy from or adapt all or part of the work\r\nin a fashion requiring copyright permission, other than the making of an\r\nexact copy.  The resulting work is called a \"modified version\" of the\r\nearlier work or a work \"based on\" the earlier work.\r\n\r\n  A \"covered work\" means either the unmodified Program or a work based\r\non the Program.\r\n\r\n  To \"propagate\" a work means to do anything with it that, without\r\npermission, would make you directly or secondarily liable for\r\ninfringement under applicable copyright law, except executing it on a\r\ncomputer or modifying a private copy.  Propagation includes copying,\r\ndistribution (with or without modification), making available to the\r\npublic, and in some countries other activities as well.\r\n\r\n  To \"convey\" a work means any kind of propagation that enables other\r\nparties to make or receive copies.  Mere interaction with a user through\r\na computer network, with no transfer of a copy, is not conveying.\r\n\r\n  An interactive user interface displays \"Appropriate Legal Notices\"\r\nto the extent that it includes a convenient and prominently visible\r\nfeature that (1) displays an appropriate copyright notice, and (2)\r\ntells the user that there is no warranty for the work (except to the\r\nextent that warranties are provided), that licensees may convey the\r\nwork under this License, and how to view a copy of this License.  If\r\nthe interface presents a list of user commands or options, such as a\r\nmenu, a prominent item in the list meets this criterion.\r\n\r\n  1. Source Code.\r\n\r\n  The \"source code\" for a work means the preferred form of the work\r\nfor making modifications to it.  \"Object code\" means any non-source\r\nform of a work.\r\n\r\n  A \"Standard Interface\" means an interface that either is an official\r\nstandard defined by a recognized standards body, or, in the case of\r\ninterfaces specified for a particular programming language, one that\r\nis widely used among developers working in that language.\r\n\r\n  The \"System Libraries\" of an executable work include anything, other\r\nthan the work as a whole, that (a) is included in the normal form of\r\npackaging a Major Component, but which is not part of that Major\r\nComponent, and (b) serves only to enable use of the work with that\r\nMajor Component, or to implement a Standard Interface for which an\r\nimplementation is available to the public in source code form.  A\r\n\"Major Component\", in this context, means a major essential component\r\n(kernel, window system, and so on) of the specific operating system\r\n(if any) on which the executable work runs, or a compiler used to\r\nproduce the work, or an object code interpreter used to run it.\r\n\r\n  The \"Corresponding Source\" for a work in object code form means all\r\nthe source code needed to generate, install, and (for an executable\r\nwork) run the object code and to modify the work, including scripts to\r\ncontrol those activities.  However, it does not include the work's\r\nSystem Libraries, or general-purpose tools or generally available free\r\nprograms which are used unmodified in performing those activities but\r\nwhich are not part of the work.  For example, Corresponding Source\r\nincludes interface definition files associated with source files for\r\nthe work, and the source code for shared libraries and dynamically\r\nlinked subprograms that the work is specifically designed to require,\r\nsuch as by intimate data communication or control flow between those\r\nsubprograms and other parts of the work.\r\n\r\n  The Corresponding Source need not include anything that users\r\ncan regenerate automatically from other parts of the Corresponding\r\nSource.\r\n\r\n  The Corresponding Source for a work in source code form is that\r\nsame work.\r\n\r\n  2. Basic Permissions.\r\n\r\n  All rights granted under this License are granted for the term of\r\ncopyright on the Program, and are irrevocable provided the stated\r\nconditions are met.  This License explicitly affirms your unlimited\r\npermission to run the unmodified Program.  The output from running a\r\ncovered work is covered by this License only if the output, given its\r\ncontent, constitutes a covered work.  This License acknowledges your\r\nrights of fair use or other equivalent, as provided by copyright law.\r\n\r\n  You may make, run and propagate covered works that you do not\r\nconvey, without conditions so long as your license otherwise remains\r\nin force.  You may convey covered works to others for the sole purpose\r\nof having them make modifications exclusively for you, or provide you\r\nwith facilities for running those works, provided that you comply with\r\nthe terms of this License in conveying all material for which you do\r\nnot control copyright.  Those thus making or running the covered works\r\nfor you must do so exclusively on your behalf, under your direction\r\nand control, on terms that prohibit them from making any copies of\r\nyour copyrighted material outside their relationship with you.\r\n\r\n  Conveying under any other circumstances is permitted solely under\r\nthe conditions stated below.  Sublicensing is not allowed; section 10\r\nmakes it unnecessary.\r\n\r\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\r\n\r\n  No covered work shall be deemed part of an effective technological\r\nmeasure under any applicable law fulfilling obligations under article\r\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\r\nsimilar laws prohibiting or restricting circumvention of such\r\nmeasures.\r\n\r\n  When you convey a covered work, you waive any legal power to forbid\r\ncircumvention of technological measures to the extent such circumvention\r\nis effected by exercising rights under this License with respect to\r\nthe covered work, and you disclaim any intention to limit operation or\r\nmodification of the work as a means of enforcing, against the work's\r\nusers, your or third parties' legal rights to forbid circumvention of\r\ntechnological measures.\r\n\r\n  4. Conveying Verbatim Copies.\r\n\r\n  You may convey verbatim copies of the Program's source code as you\r\nreceive it, in any medium, provided that you conspicuously and\r\nappropriately publish on each copy an appropriate copyright notice;\r\nkeep intact all notices stating that this License and any\r\nnon-permissive terms added in accord with section 7 apply to the code;\r\nkeep intact all notices of the absence of any warranty; and give all\r\nrecipients a copy of this License along with the Program.\r\n\r\n  You may charge any price or no price for each copy that you convey,\r\nand you may offer support or warranty protection for a fee.\r\n\r\n  5. Conveying Modified Source Versions.\r\n\r\n  You may convey a work based on the Program, or the modifications to\r\nproduce it from the Program, in the form of source code under the\r\nterms of section 4, provided that you also meet all of these conditions:\r\n\r\n    a) The work must carry prominent notices stating that you modified\r\n    it, and giving a relevant date.\r\n\r\n    b) The work must carry prominent notices stating that it is\r\n    released under this License and any conditions added under section\r\n    7.  This requirement modifies the requirement in section 4 to\r\n    \"keep intact all notices\".\r\n\r\n    c) You must license the entire work, as a whole, under this\r\n    License to anyone who comes into possession of a copy.  This\r\n    License will therefore apply, along with any applicable section 7\r\n    additional terms, to the whole of the work, and all its parts,\r\n    regardless of how they are packaged.  This License gives no\r\n    permission to license the work in any other way, but it does not\r\n    invalidate such permission if you have separately received it.\r\n\r\n    d) If the work has interactive user interfaces, each must display\r\n    Appropriate Legal Notices; however, if the Program has interactive\r\n    interfaces that do not display Appropriate Legal Notices, your\r\n    work need not make them do so.\r\n\r\n  A compilation of a covered work with other separate and independent\r\nworks, which are not by their nature extensions of the covered work,\r\nand which are not combined with it such as to form a larger program,\r\nin or on a volume of a storage or distribution medium, is called an\r\n\"aggregate\" if the compilation and its resulting copyright are not\r\nused to limit the access or legal rights of the compilation's users\r\nbeyond what the individual works permit.  Inclusion of a covered work\r\nin an aggregate does not cause this License to apply to the other\r\nparts of the aggregate.\r\n\r\n  6. Conveying Non-Source Forms.\r\n\r\n  You may convey a covered work in object code form under the terms\r\nof sections 4 and 5, provided that you also convey the\r\nmachine-readable Corresponding Source under the terms of this License,\r\nin one of these ways:\r\n\r\n    a) Convey the object code in, or embodied in, a physical product\r\n    (including a physical distribution medium), accompanied by the\r\n    Corresponding Source fixed on a durable physical medium\r\n    customarily used for software interchange.\r\n\r\n    b) Convey the object code in, or embodied in, a physical product\r\n    (including a physical distribution medium), accompanied by a\r\n    written offer, valid for at least three years and valid for as\r\n    long as you offer spare parts or customer support for that product\r\n    model, to give anyone who possesses the object code either (1) a\r\n    copy of the Corresponding Source for all the software in the\r\n    product that is covered by this License, on a durable physical\r\n    medium customarily used for software interchange, for a price no\r\n    more than your reasonable cost of physically performing this\r\n    conveying of source, or (2) access to copy the\r\n    Corresponding Source from a network server at no charge.\r\n\r\n    c) Convey individual copies of the object code with a copy of the\r\n    written offer to provide the Corresponding Source.  This\r\n    alternative is allowed only occasionally and noncommercially, and\r\n    only if you received the object code with such an offer, in accord\r\n    with subsection 6b.\r\n\r\n    d) Convey the object code by offering access from a designated\r\n    place (gratis or for a charge), and offer equivalent access to the\r\n    Corresponding Source in the same way through the same place at no\r\n    further charge.  You need not require recipients to copy the\r\n    Corresponding Source along with the object code.  If the place to\r\n    copy the object code is a network server, the Corresponding Source\r\n    may be on a different server (operated by you or a third party)\r\n    that supports equivalent copying facilities, provided you maintain\r\n    clear directions next to the object code saying where to find the\r\n    Corresponding Source.  Regardless of what server hosts the\r\n    Corresponding Source, you remain obligated to ensure that it is\r\n    available for as long as needed to satisfy these requirements.\r\n\r\n    e) Convey the object code using peer-to-peer transmission, provided\r\n    you inform other peers where the object code and Corresponding\r\n    Source of the work are being offered to the general public at no\r\n    charge under subsection 6d.\r\n\r\n  A separable portion of the object code, whose source code is excluded\r\nfrom the Corresponding Source as a System Library, need not be\r\nincluded in conveying the object code work.\r\n\r\n  A \"User Product\" is either (1) a \"consumer product\", which means any\r\ntangible personal property which is normally used for personal, family,\r\nor household purposes, or (2) anything designed or sold for incorporation\r\ninto a dwelling.  In determining whether a product is a consumer product,\r\ndoubtful cases shall be resolved in favor of coverage.  For a particular\r\nproduct received by a particular user, \"normally used\" refers to a\r\ntypical or common use of that class of product, regardless of the status\r\nof the particular user or of the way in which the particular user\r\nactually uses, or expects or is expected to use, the product.  A product\r\nis a consumer product regardless of whether the product has substantial\r\ncommercial, industrial or non-consumer uses, unless such uses represent\r\nthe only significant mode of use of the product.\r\n\r\n  \"Installation Information\" for a User Product means any methods,\r\nprocedures, authorization keys, or other information required to install\r\nand execute modified versions of a covered work in that User Product from\r\na modified version of its Corresponding Source.  The information must\r\nsuffice to ensure that the continued functioning of the modified object\r\ncode is in no case prevented or interfered with solely because\r\nmodification has been made.\r\n\r\n  If you convey an object code work under this section in, or with, or\r\nspecifically for use in, a User Product, and the conveying occurs as\r\npart of a transaction in which the right of possession and use of the\r\nUser Product is transferred to the recipient in perpetuity or for a\r\nfixed term (regardless of how the transaction is characterized), the\r\nCorresponding Source conveyed under this section must be accompanied\r\nby the Installation Information.  But this requirement does not apply\r\nif neither you nor any third party retains the ability to install\r\nmodified object code on the User Product (for example, the work has\r\nbeen installed in ROM).\r\n\r\n  The requirement to provide Installation Information does not include a\r\nrequirement to continue to provide support service, warranty, or updates\r\nfor a work that has been modified or installed by the recipient, or for\r\nthe User Product in which it has been modified or installed.  Access to a\r\nnetwork may be denied when the modification itself materially and\r\nadversely affects the operation of the network or violates the rules and\r\nprotocols for communication across the network.\r\n\r\n  Corresponding Source conveyed, and Installation Information provided,\r\nin accord with this section must be in a format that is publicly\r\ndocumented (and with an implementation available to the public in\r\nsource code form), and must require no special password or key for\r\nunpacking, reading or copying.\r\n\r\n  7. Additional Terms.\r\n\r\n  \"Additional permissions\" are terms that supplement the terms of this\r\nLicense by making exceptions from one or more of its conditions.\r\nAdditional permissions that are applicable to the entire Program shall\r\nbe treated as though they were included in this License, to the extent\r\nthat they are valid under applicable law.  If additional permissions\r\napply only to part of the Program, that part may be used separately\r\nunder those permissions, but the entire Program remains governed by\r\nthis License without regard to the additional permissions.\r\n\r\n  When you convey a copy of a covered work, you may at your option\r\nremove any additional permissions from that copy, or from any part of\r\nit.  (Additional permissions may be written to require their own\r\nremoval in certain cases when you modify the work.)  You may place\r\nadditional permissions on material, added by you to a covered work,\r\nfor which you have or can give appropriate copyright permission.\r\n\r\n  Notwithstanding any other provision of this License, for material you\r\nadd to a covered work, you may (if authorized by the copyright holders of\r\nthat material) supplement the terms of this License with terms:\r\n\r\n    a) Disclaiming warranty or limiting liability differently from the\r\n    terms of sections 15 and 16 of this License; or\r\n\r\n    b) Requiring preservation of specified reasonable legal notices or\r\n    author attributions in that material or in the Appropriate Legal\r\n    Notices displayed by works containing it; or\r\n\r\n    c) Prohibiting misrepresentation of the origin of that material, or\r\n    requiring that modified versions of such material be marked in\r\n    reasonable ways as different from the original version; or\r\n\r\n    d) Limiting the use for publicity purposes of names of licensors or\r\n    authors of the material; or\r\n\r\n    e) Declining to grant rights under trademark law for use of some\r\n    trade names, trademarks, or service marks; or\r\n\r\n    f) Requiring indemnification of licensors and authors of that\r\n    material by anyone who conveys the material (or modified versions of\r\n    it) with contractual assumptions of liability to the recipient, for\r\n    any liability that these contractual assumptions directly impose on\r\n    those licensors and authors.\r\n\r\n  All other non-permissive additional terms are considered \"further\r\nrestrictions\" within the meaning of section 10.  If the Program as you\r\nreceived it, or any part of it, contains a notice stating that it is\r\ngoverned by this License along with a term that is a further\r\nrestriction, you may remove that term.  If a license document contains\r\na further restriction but permits relicensing or conveying under this\r\nLicense, you may add to a covered work material governed by the terms\r\nof that license document, provided that the further restriction does\r\nnot survive such relicensing or conveying.\r\n\r\n  If you add terms to a covered work in accord with this section, you\r\nmust place, in the relevant source files, a statement of the\r\nadditional terms that apply to those files, or a notice indicating\r\nwhere to find the applicable terms.\r\n\r\n  Additional terms, permissive or non-permissive, may be stated in the\r\nform of a separately written license, or stated as exceptions;\r\nthe above requirements apply either way.\r\n\r\n  8. Termination.\r\n\r\n  You may not propagate or modify a covered work except as expressly\r\nprovided under this License.  Any attempt otherwise to propagate or\r\nmodify it is void, and will automatically terminate your rights under\r\nthis License (including any patent licenses granted under the third\r\nparagraph of section 11).\r\n\r\n  However, if you cease all violation of this License, then your\r\nlicense from a particular copyright holder is reinstated (a)\r\nprovisionally, unless and until the copyright holder explicitly and\r\nfinally terminates your license, and (b) permanently, if the copyright\r\nholder fails to notify you of the violation by some reasonable means\r\nprior to 60 days after the cessation.\r\n\r\n  Moreover, your license from a particular copyright holder is\r\nreinstated permanently if the copyright holder notifies you of the\r\nviolation by some reasonable means, this is the first time you have\r\nreceived notice of violation of this License (for any work) from that\r\ncopyright holder, and you cure the violation prior to 30 days after\r\nyour receipt of the notice.\r\n\r\n  Termination of your rights under this section does not terminate the\r\nlicenses of parties who have received copies or rights from you under\r\nthis License.  If your rights have been terminated and not permanently\r\nreinstated, you do not qualify to receive new licenses for the same\r\nmaterial under section 10.\r\n\r\n  9. Acceptance Not Required for Having Copies.\r\n\r\n  You are not required to accept this License in order to receive or\r\nrun a copy of the Program.  Ancillary propagation of a covered work\r\noccurring solely as a consequence of using peer-to-peer transmission\r\nto receive a copy likewise does not require acceptance.  However,\r\nnothing other than this License grants you permission to propagate or\r\nmodify any covered work.  These actions infringe copyright if you do\r\nnot accept this License.  Therefore, by modifying or propagating a\r\ncovered work, you indicate your acceptance of this License to do so.\r\n\r\n  10. Automatic Licensing of Downstream Recipients.\r\n\r\n  Each time you convey a covered work, the recipient automatically\r\nreceives a license from the original licensors, to run, modify and\r\npropagate that work, subject to this License.  You are not responsible\r\nfor enforcing compliance by third parties with this License.\r\n\r\n  An \"entity transaction\" is a transaction transferring control of an\r\norganization, or substantially all assets of one, or subdividing an\r\norganization, or merging organizations.  If propagation of a covered\r\nwork results from an entity transaction, each party to that\r\ntransaction who receives a copy of the work also receives whatever\r\nlicenses to the work the party's predecessor in interest had or could\r\ngive under the previous paragraph, plus a right to possession of the\r\nCorresponding Source of the work from the predecessor in interest, if\r\nthe predecessor has it or can get it with reasonable efforts.\r\n\r\n  You may not impose any further restrictions on the exercise of the\r\nrights granted or affirmed under this License.  For example, you may\r\nnot impose a license fee, royalty, or other charge for exercise of\r\nrights granted under this License, and you may not initiate litigation\r\n(including a cross-claim or counterclaim in a lawsuit) alleging that\r\nany patent claim is infringed by making, using, selling, offering for\r\nsale, or importing the Program or any portion of it.\r\n\r\n  11. Patents.\r\n\r\n  A \"contributor\" is a copyright holder who authorizes use under this\r\nLicense of the Program or a work on which the Program is based.  The\r\nwork thus licensed is called the contributor's \"contributor version\".\r\n\r\n  A contributor's \"essential patent claims\" are all patent claims\r\nowned or controlled by the contributor, whether already acquired or\r\nhereafter acquired, that would be infringed by some manner, permitted\r\nby this License, of making, using, or selling its contributor version,\r\nbut do not include claims that would be infringed only as a\r\nconsequence of further modification of the contributor version.  For\r\npurposes of this definition, \"control\" includes the right to grant\r\npatent sublicenses in a manner consistent with the requirements of\r\nthis License.\r\n\r\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\r\npatent license under the contributor's essential patent claims, to\r\nmake, use, sell, offer for sale, import and otherwise run, modify and\r\npropagate the contents of its contributor version.\r\n\r\n  In the following three paragraphs, a \"patent license\" is any express\r\nagreement or commitment, however denominated, not to enforce a patent\r\n(such as an express permission to practice a patent or covenant not to\r\nsue for patent infringement).  To \"grant\" such a patent license to a\r\nparty means to make such an agreement or commitment not to enforce a\r\npatent against the party.\r\n\r\n  If you convey a covered work, knowingly relying on a patent license,\r\nand the Corresponding Source of the work is not available for anyone\r\nto copy, free of charge and under the terms of this License, through a\r\npublicly available network server or other readily accessible means,\r\nthen you must either (1) cause the Corresponding Source to be so\r\navailable, or (2) arrange to deprive yourself of the benefit of the\r\npatent license for this particular work, or (3) arrange, in a manner\r\nconsistent with the requirements of this License, to extend the patent\r\nlicense to downstream recipients.  \"Knowingly relying\" means you have\r\nactual knowledge that, but for the patent license, your conveying the\r\ncovered work in a country, or your recipient's use of the covered work\r\nin a country, would infringe one or more identifiable patents in that\r\ncountry that you have reason to believe are valid.\r\n\r\n  If, pursuant to or in connection with a single transaction or\r\narrangement, you convey, or propagate by procuring conveyance of, a\r\ncovered work, and grant a patent license to some of the parties\r\nreceiving the covered work authorizing them to use, propagate, modify\r\nor convey a specific copy of the covered work, then the patent license\r\nyou grant is automatically extended to all recipients of the covered\r\nwork and works based on it.\r\n\r\n  A patent license is \"discriminatory\" if it does not include within\r\nthe scope of its coverage, prohibits the exercise of, or is\r\nconditioned on the non-exercise of one or more of the rights that are\r\nspecifically granted under this License.  You may not convey a covered\r\nwork if you are a party to an arrangement with a third party that is\r\nin the business of distributing software, under which you make payment\r\nto the third party based on the extent of your activity of conveying\r\nthe work, and under which the third party grants, to any of the\r\nparties who would receive the covered work from you, a discriminatory\r\npatent license (a) in connection with copies of the covered work\r\nconveyed by you (or copies made from those copies), or (b) primarily\r\nfor and in connection with specific products or compilations that\r\ncontain the covered work, unless you entered into that arrangement,\r\nor that patent license was granted, prior to 28 March 2007.\r\n\r\n  Nothing in this License shall be construed as excluding or limiting\r\nany implied license or other defenses to infringement that may\r\notherwise be available to you under applicable patent law.\r\n\r\n  12. No Surrender of Others' Freedom.\r\n\r\n  If conditions are imposed on you (whether by court order, agreement or\r\notherwise) that contradict the conditions of this License, they do not\r\nexcuse you from the conditions of this License.  If you cannot convey a\r\ncovered work so as to satisfy simultaneously your obligations under this\r\nLicense and any other pertinent obligations, then as a consequence you may\r\nnot convey it at all.  For example, if you agree to terms that obligate you\r\nto collect a royalty for further conveying from those to whom you convey\r\nthe Program, the only way you could satisfy both those terms and this\r\nLicense would be to refrain entirely from conveying the Program.\r\n\r\n  13. Use with the GNU Affero General Public License.\r\n\r\n  Notwithstanding any other provision of this License, you have\r\npermission to link or combine any covered work with a work licensed\r\nunder version 3 of the GNU Affero General Public License into a single\r\ncombined work, and to convey the resulting work.  The terms of this\r\nLicense will continue to apply to the part which is the covered work,\r\nbut the special requirements of the GNU Affero General Public License,\r\nsection 13, concerning interaction through a network will apply to the\r\ncombination as such.\r\n\r\n  14. Revised Versions of this License.\r\n\r\n  The Free Software Foundation may publish revised and/or new versions of\r\nthe GNU General Public License from time to time.  Such new versions will\r\nbe similar in spirit to the present version, but may differ in detail to\r\naddress new problems or concerns.\r\n\r\n  Each version is given a distinguishing version number.  If the\r\nProgram specifies that a certain numbered version of the GNU General\r\nPublic License \"or any later version\" applies to it, you have the\r\noption of following the terms and conditions either of that numbered\r\nversion or of any later version published by the Free Software\r\nFoundation.  If the Program does not specify a version number of the\r\nGNU General Public License, you may choose any version ever published\r\nby the Free Software Foundation.\r\n\r\n  If the Program specifies that a proxy can decide which future\r\nversions of the GNU General Public License can be used, that proxy's\r\npublic statement of acceptance of a version permanently authorizes you\r\nto choose that version for the Program.\r\n\r\n  Later license versions may give you additional or different\r\npermissions.  However, no additional obligations are imposed on any\r\nauthor or copyright holder as a result of your choosing to follow a\r\nlater version.\r\n\r\n  15. Disclaimer of Warranty.\r\n\r\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\r\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\r\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\r\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\r\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\r\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\r\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\r\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\r\n\r\n  16. Limitation of Liability.\r\n\r\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\r\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\r\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\r\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\r\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\r\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\r\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\r\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\r\nSUCH DAMAGES.\r\n\r\n  17. Interpretation of Sections 15 and 16.\r\n\r\n  If the disclaimer of warranty and limitation of liability provided\r\nabove cannot be given local legal effect according to their terms,\r\nreviewing courts shall apply local law that most closely approximates\r\nan absolute waiver of all civil liability in connection with the\r\nProgram, unless a warranty or assumption of liability accompanies a\r\ncopy of the Program in return for a fee.\r\n\r\n                     END OF TERMS AND CONDITIONS\r\n\r\n            How to Apply These Terms to Your New Programs\r\n\r\n  If you develop a new program, and you want it to be of the greatest\r\npossible use to the public, the best way to achieve this is to make it\r\nfree software which everyone can redistribute and change under these terms.\r\n\r\n  To do so, attach the following notices to the program.  It is safest\r\nto attach them to the start of each source file to most effectively\r\nstate the exclusion of warranty; and each file should have at least\r\nthe \"copyright\" line and a pointer to where the full notice is found.\r\n\r\n    <one line to give the program's name and a brief idea of what it does.>\r\n    Copyright (C) <year>  <name of author>\r\n\r\n    This program is free software: you can redistribute it and/or modify\r\n    it under the terms of the GNU General Public License as published by\r\n    the Free Software Foundation, either version 3 of the License, or\r\n    (at your option) any later version.\r\n\r\n    This program is distributed in the hope that it will be useful,\r\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\r\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\r\n    GNU General Public License for more details.\r\n\r\n    You should have received a copy of the GNU General Public License\r\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\r\n\r\nAlso add information on how to contact you by electronic and paper mail.\r\n\r\n  If the program does terminal interaction, make it output a short\r\nnotice like this when it starts in an interactive mode:\r\n\r\n    <program>  Copyright (C) <year>  <name of author>\r\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\r\n    This is free software, and you are welcome to redistribute it\r\n    under certain conditions; type `show c' for details.\r\n\r\nThe hypothetical commands `show w' and `show c' should show the appropriate\r\nparts of the General Public License.  Of course, your program's commands\r\nmight be different; for a GUI interface, you would use an \"about box\".\r\n\r\n  You should also get your employer (if you work as a programmer) or school,\r\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\r\nFor more information on this, and how to apply and follow the GNU GPL, see\r\n<https://www.gnu.org/licenses/>.\r\n\r\n  The GNU General Public License does not permit incorporating your program\r\ninto proprietary programs.  If your program is a subroutine library, you\r\nmay consider it more useful to permit linking proprietary applications with\r\nthe library.  If this is what you want to do, use the GNU Lesser General\r\nPublic License instead of this License.  But first, please read\r\n<https://www.gnu.org/licenses/why-not-lgpl.html>.\r\n"
  },
  {
    "path": "README.md",
    "content": "# comfy-cli: A Command Line Tool for ComfyUI\n\n[![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)\n[![codecov](https://codecov.io/github/Comfy-Org/comfy-cli/graph/badge.svg?token=S64WJWD2ZX)](https://codecov.io/github/Comfy-Org/comfy-cli)\n[![PyPI](https://img.shields.io/pypi/v/comfy-cli.svg)](https://pypi.org/project/comfy-cli/)\n[![Downloads](https://static.pepy.tech/badge/comfy-cli/month)](https://pepy.tech/project/comfy-cli)\n[![Python](https://img.shields.io/pypi/pyversions/comfy-cli)](https://pypi.org/project/comfy-cli/)\n[![License](https://img.shields.io/pypi/l/comfy-cli)](https://github.com/Comfy-Org/comfy-cli/blob/main/LICENSE)\n\ncomfy-cli is a command-line tool for installing, running, and extending\n[ComfyUI](https://github.com/comfyanonymous/ComfyUI) — the open-source\ngenerative-media engine. Set up ComfyUI, install custom nodes and models, run\nworkflows, and call hosted partner image models, all from your terminal.\n\n## Demo\n\n<img src=\"https://github.com/yoland68/comfy-cli/raw/main/assets/comfy-demo.gif\" width=\"400\" alt=\"Comfy Command Demo\">\n\n## Features\n\n- 🚀 One-command ComfyUI install and launch\n- 🎨 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\n- 🔧 Custom node management — install, update, snapshot, bisect\n- 📦 Fast dependency resolution with `uv` (`--fast-deps`, `--uv-compile`)\n- 🗄️ Model downloads from CivitAI, Hugging Face, and direct URLs\n- 🎬 Run workflows against a local ComfyUI server, including auto-conversion of UI-format JSON\n- 🧪 Test ComfyUI and frontend pull requests with one flag\n- 💻 Cross-platform: Windows, macOS, Linux\n\n## Installation\n\n1. (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)).\n\n2. Install with `pip` (requires Python 3.10+):\n\n   ```bash\n   pip install comfy-cli\n   ```\n\n### Shell Autocomplete\n\nInstall shell completion so `comfy <TAB>` expands commands and options:\n\n```bash\ncomfy --install-completion\n```\n\n## Usage\n\n### Installing ComfyUI\n\nTo install ComfyUI using comfy, simply run:\n\n`comfy install`\n\nThis command will download and set up the latest version of ComfyUI and ComfyUI-Manager on your\nsystem. If you run in a ComfyUI repo that has already been setup. The command\nwill simply update the comfy.yaml file to reflect the local setup\n\n- `comfy install --skip-manager`: Install ComfyUI without ComfyUI-Manager.\n  To use a custom Manager fork or specific version, skip the default installation\n  and install your own into the workspace venv:\n  ```bash\n  comfy install --skip-manager\n  # Then install your custom Manager:\n  pip install -e /path/to/your-manager-fork   # editable install\n  # or\n  pip install comfyui-manager==4.1b8          # specific version\n  ```\n- `comfy --workspace=<path> install`: Install ComfyUI into `<path>/ComfyUI`.\n- `comfy install --fast-deps`: Use `uv` instead of `pip` for faster dependency resolution\n  during initial ComfyUI installation. comfy-cli's built-in resolver compiles all requirements (core + custom nodes)\n  into a single lockfile and installs from it. Also handles GPU-specific PyTorch wheel selection automatically.\n- For `comfy install`, if no path specification like `--workspace, --recent, or --here` is provided, it will be implicitly installed in `<HOME>/comfy`.\n\n#### Python environment handling\n\nWhen you run `comfy install`, comfy-cli picks a Python environment for ComfyUI\ndependencies using the following precedence:\n\n1. An **active virtualenv or conda** environment (`VIRTUAL_ENV` / `CONDA_PREFIX`) is used as-is.\n2. An **existing `.venv` or `venv`** directory inside the workspace is reused.\n3. Otherwise the choice depends on how comfy-cli was installed:\n   - **`pip install comfy-cli`** (global / system Python): dependencies go\n     directly into the same Python environment. This is the typical Docker setup.\n   - **`pipx install comfy-cli`** or **`uv tool install comfy-cli`** (isolated\n     tool environment): a `.venv` is created inside the ComfyUI workspace.\n     Use `comfy launch` to start ComfyUI with the correct Python.\n\n### Specifying execution path\n\n- You can specify the path of ComfyUI where the command will be applied through path indicators as follows:\n  - `comfy --workspace=<path>`: Run from the ComfyUI installed in the specified workspace.\n  - `comfy --recent`: Run from the recently executed or installed ComfyUI.\n  - `comfy --here`: Run from the ComfyUI located in the current directory.\n- --workspace, --recent, and --here options cannot be used simultaneously.\n- If there is no path indicator, the following priority applies:\n\n  - Run from the default ComfyUI at the path specified by `comfy set-default <path>`.\n  - Run from the recently executed or installed ComfyUI.\n  - Run from the ComfyUI located in the current directory.\n\n- Example 1: To run the recently executed ComfyUI:\n  - `comfy --recent launch`\n- Example 2: To install a package on the ComfyUI in the current directory:\n  - `comfy --here node install comfyui-impact-pack`\n- Example 3: To update the automatically selected path of ComfyUI and custom nodes based on priority:\n\n  - `comfy node update all`\n\n- You can use the `comfy which` command to check the path of the target workspace.\n  - e.g `comfy --recent which`, `comfy --here which`, `comfy which`, ...\n\n### Default Setup\n\nThe default sets the option that will be executed by default when no specific workspace's ComfyUI has been set for the command.\n\n`comfy set-default <workspace path> ?[--launch-extras=\"<extra args>\"]`\n\n- `--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.\n\n### Launch ComfyUI\n\nComfy provides commands that allow you to easily run the installed ComfyUI.\n\n`comfy launch`\n\n- To run with default ComfyUI options:\n\n  `comfy launch -- <extra args...>`\n\n  `comfy launch -- --cpu --listen 0.0.0.0`\n\n  - When you manually configure the extra options, the extras set by set-default will be overridden.\n\n- To run background\n\n  `comfy launch --background`\n\n  `comfy --workspace=~/comfy launch --background -- --listen 10.0.0.10 --port 8000`\n\n  - Instances launched with `--background` are displayed in the \"Background ComfyUI\" section of `comfy env`, providing management functionalities for a single background instance only.\n  - Since \"Comfy Server Running\" in `comfy env` only shows the default port 8188, it doesn't display ComfyUI running on a different port.\n  - Background-running ComfyUI can be stopped with `comfy stop`.\n\n- to run ComfyUI with a specific pull request:\n\n  `comfy install --pr \"#1234\"`\n\n  `comfy install --pr \"jtydhr88:load-3d-nodes\"`\n\n  `comfy install --pr \"https://github.com/comfyanonymous/ComfyUI/pull/1234\"`\n\n  - 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.\n  - Important: The --pr option cannot be combined with --version or --commit and will be rejected if used together.\n\n- To test a frontend pull request:\n\n  ```\n  comfy launch --frontend-pr \"#456\"\n  comfy launch --frontend-pr \"username:branch-name\"\n  comfy launch --frontend-pr \"https://github.com/Comfy-Org/ComfyUI_frontend/pull/456\"\n  ```\n\n  - The `--frontend-pr` option allows you to test frontend PRs by automatically cloning, building, and using the frontend for that session.\n  - Requirements: Node.js and npm must be installed to build the frontend.\n  - Builds are cached for quick switching between PRs - subsequent uses of the same PR are instant.\n  - Each PR is used only for that launch session. Normal launches use the default frontend.\n\n  **Managing PR cache**:\n  ```\n  comfy pr-cache list              # List cached PR builds\n  comfy pr-cache clean             # Clean all cached builds\n  comfy pr-cache clean 456         # Clean specific PR cache\n  ```\n\n  - Cache automatically expires after 7 days\n  - Maximum of 10 PR builds are kept (oldest are removed automatically)\n  - Cache limits help manage disk space while keeping recent builds available\n\n### Managing Custom Nodes\n\ncomfy provides a convenient way to manage custom nodes for extending ComfyUI's functionality. Here are some examples:\n\n- Show custom nodes' information:\n\n```\ncomfy node [show|simple-show] [installed|enabled|not-installed|disabled|all|snapshot|snapshot-list]\n                             ?[--channel <channel name>]\n                             ?[--mode [remote|local|cache]]\n```\n\n- `comfy node show all --channel recent`\n\n  `comfy node simple-show installed`\n\n  `comfy node update all`\n\n  `comfy node install comfyui-impact-pack`\n\n- Managing snapshot:\n\n  `comfy node save-snapshot`\n\n  `comfy node restore-snapshot <snapshot name>`\n\n- Install dependencies:\n\n  `comfy node install-deps --deps=<deps .json file>`\n\n  `comfy node install-deps --workflow=<workflow .json/.png file>`\n\n- Generate deps:\n\n  `comfy node deps-in-workflow --workflow=<workflow .json/.png file> --output=<output deps .json file>`\n\n#### Unified Dependency Resolution (--uv-compile)\n\nRequires ComfyUI-Manager v4.1+. Instead of installing dependencies per-node with\n`pip install`, `--uv-compile` delegates to ComfyUI-Manager's unified resolver which batch-resolves\nall custom node dependencies via `uv pip compile` with **cross-node conflict detection** —\nit can identify which node packs have incompatible dependencies and why.\n\n- Install with unified resolution:\n\n  `comfy node install comfyui-impact-pack --uv-compile`\n\n- Available on: `install`, `reinstall`, `update`, `fix`, `restore-snapshot`,\n  `restore-dependencies`, `install-deps`\n\n- Run standalone (resolve all existing custom node dependencies):\n\n  `comfy node uv-sync`\n\n- `--uv-compile` is mutually exclusive with `--fast-deps` and `--no-deps`.\n\n- To make `--uv-compile` the default for all commands, see\n  [uv-compile default](#uv-compile-default) below.\n\n- Use `--no-uv-compile` to override the default for a single command:\n\n  `comfy node install comfyui-impact-pack --no-uv-compile`\n\n#### --fast-deps vs --uv-compile\n\nBoth flags use `uv` for faster dependency resolution, but they work differently:\n\n|                       | `--fast-deps`                                   | `--uv-compile`                                |\n|-----------------------|-------------------------------------------------|-----------------------------------------------|\n| **Resolver**          | comfy-cli built-in (`DependencyCompiler`)       | ComfyUI-Manager (`UnifiedDepResolver`)        |\n| **Scope**             | `comfy install`, `comfy node install/reinstall` | Custom node commands only                     |\n| **Conflict handling** | Interactive prompt to pick a version            | Automatic detection with node attribution     |\n| **Config default**    | No                                              | Yes (`comfy manager uv-compile-default true`) |\n| **Requires**          | Only `uv`                                       | ComfyUI-Manager v4.1+                         |\n\n**When to use which:**\n- For initial ComfyUI installation with uv: `comfy install --fast-deps`\n- For custom node management with Manager v4.1+: `--uv-compile` (recommended)\n- For custom node management with older Manager: `--fast-deps`\n\n#### Bisect custom nodes\n\nIf you encounter bugs only with custom nodes enabled, and want to find out which custom node(s) causes the bug,\nthe bisect tool can help you pinpoint the custom node that causes the issue.\n\n- `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.\n- `comfy node bisect good`: Mark the current active set as good, indicating the problem is not within the test set.\n- `comfy node bisect bad`: Mark the current active set as bad, indicating the problem is within the test set.\n- `comfy node bisect reset`: Reset the current bisect session.\n\n### Managing Models\n\n- Model downloading\n\n  `comfy model download --url <URL> ?[--relative-path <PATH>] ?[--set-civitai-api-token <TOKEN>] ?[--set-hf-api-token <TOKEN>]`\n\n  - URL: CivitAI page, Hugging Face file URL, etc...\n  - 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).\n  - 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.\n\n- Model remove\n\n  `comfy model remove ?[--relative-path <PATH>] --model-names <model names>`\n\n- Model list\n\n  `comfy model list ?[--relative-path <PATH>]`\n\n### Calling partner nodes (`comfy generate`)\n\n`comfy generate` calls Comfy's partner nodes directly from the terminal — no\nlocal ComfyUI or workflow JSON required. It hits the same hosted partner nodes\nyou'd otherwise wire into a ComfyUI workflow, but as one-shot CLI calls. Image\nmodels (Flux, Ideogram, DALL·E, Recraft, Stability, Runway, Reve, xAI Grok,\nGoogle Gemini Flash Image aka **nano-banana**, …) and video models (Kling,\nLuma, Runway Gen-3, Pika, Vidu, Moonvalley, Hailuo, Grok video, ByteDance\n**Seedance**) are all covered; video jobs run async and the CLI polls until\nthe result is ready.\n\nPrerequisites — a Comfy API key and a credit balance:\n\n- [Create an API key](https://docs.comfy.org/development/comfyui-server/api-key-integration)\n- [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)\n- [Add credits](https://docs.comfy.org/interface/credits)\n\nSet the key once, then go:\n\n```bash\nexport COMFY_API_KEY=comfyui-...   # or pass --api-key on each call\n\ncomfy generate list                                  # browse available models\ncomfy generate schema flux-pro                       # see params for one model\ncomfy generate flux-pro --prompt \"a cat on the moon\" \\\n    --width 1024 --height 1024 --download cat.png\n```\n\nReference images can be passed as local paths — the CLI uploads them through\nthe cloud's storage endpoint (or base64-encodes inline, as each partner\nrequires):\n\n```bash\ncomfy generate flux-kontext --prompt \"add a top hat\" \\\n    --input_image ./photo.jpg --download out.png\n\ncomfy generate upload ./photo.jpg                    # explicit upload\n```\n\nAsync models (every video model plus the Flux family) block until ready by\ndefault. Pass `--async` to return immediately with a job id, then resume later\nwith `comfy generate resume <model> <job_id>`. Examples:\n\n```bash\ncomfy generate kling --prompt \"a paper boat drifting on a river at dusk\" \\\n    --duration 5 --download boat.mp4\n\ncomfy generate luma --prompt \"...\" --aspect_ratio 16:9 --async\n# → prints job id; resume with:\ncomfy generate resume luma <job_id> --download out.mp4\n```\n\n**Gemini Flash Image (nano-banana)** — text-to-image and image edits in one\nalias. Pass `--image` (repeatable) for reference images. The response is\ninline base64, so `--download` is required to save:\n\n```bash\ncomfy generate nano-banana --prompt \"a watercolor of a sleeping fox\" \\\n    --download fox.png\n\n# Image edit — reference accepted as a local path, http(s) URL, or data URI:\ncomfy generate nano-banana --prompt \"add a top hat\" \\\n    --image ./cat.png --download out.png\n\n# Switch model variants:\ncomfy generate nano-banana --prompt \"...\" --model gemini-3-pro-image-preview \\\n    --download out.png\n```\n\n**Seedance** — text-to-video and image-to-video, up to 1080p / 12s clips.\nResolution, ratio, duration, fps, etc. get passed through as flags; the CLI\ninlines them into Seedance's prompt syntax for you:\n\n```bash\ncomfy generate seedance --prompt \"a hummingbird hovering over a flower\" \\\n    --resolution 1080p --duration 5 --download bird.mp4\n\n# Image-to-video: pick a lite/i2v variant and pass a first frame.\ncomfy generate seedance --model seedance-1-0-lite-i2v-250428 \\\n    --prompt \"the wave crests and crashes\" \\\n    --image ./still.jpg --download wave.mp4\n```\n\n### Managing ComfyUI-Manager\n\n- Disable ComfyUI-Manager completely (no manager flags passed to ComfyUI):\n\n  `comfy manager disable`\n\n- Enable ComfyUI-Manager with new GUI:\n\n  `comfy manager enable-gui`\n\n- Enable ComfyUI-Manager without GUI (manager runs but UI is hidden):\n\n  `comfy manager disable-gui`\n\n- Enable ComfyUI-Manager with legacy GUI:\n\n  `comfy manager enable-legacy-gui`\n\n- Clear reserved startup action:\n\n  `comfy manager clear`\n\n- Migrate legacy git-cloned ComfyUI-Manager to pip package:\n\n  `comfy manager migrate-legacy`\n\n#### uv-compile default\n\nSet `--uv-compile` as the default behavior for all custom node operations:\n\n  `comfy manager uv-compile-default true`\n\nWhen enabled, all node commands (`install`, `reinstall`, `update`, `fix`,\n`restore-snapshot`, `restore-dependencies`, `install-deps`) will automatically\nuse `--uv-compile`. Use `--no-uv-compile` on any individual command to override.\n\nTo disable:\n\n  `comfy manager uv-compile-default false`\n\n## Beta Feature: format of comfy-lock.yaml (WIP)\n\n```\nbasic:\n\nmodels:\n  - model: [name of the model]\n    url: [url of the source, e.g. https://huggingface.co/...]\n    paths: [list of paths to the model]\n      - path: [path to the model]\n      - path: [path to the model]\n    hashes: [hashes for the model]\n      - hash: [hash]\n        type: [AutoV1, AutoV2, SHA256, CRC32, and Blake3]\n    type: [type of the model, e.g. diffuser, lora, etc.]\n\n  - model:\n  ...\n\n# compatible with ComfyUI-Manager's .yaml snapshot\ncustom_nodes:\n  comfyui: [commit hash]\n  file_custom_nodes:\n  - disabled: [bool]\n    filename: [.py filename]\n    ...\n  git_custom_nodes:\n    [git-url]:\n      disabled: [bool]\n      hash: [commit hash]\n    ...\n```\n\n## Analytics\n\nWe 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:\n\n```\ncomfy tracking disable\n```\n\nCheck out the usage here: [Mixpanel Board](https://mixpanel.com/p/13hGfPfEPdRkjPtNaS7BYQ)\n\n## Contributing\n\nWe welcome contributions to comfy-cli! For ideas, suggestions, or bug reports,\nopen an issue at [Comfy-Org/comfy-cli](https://github.com/Comfy-Org/comfy-cli/issues).\nFor code changes, fork the repo and open a pull request.\n\nSee the [Dev Guide](/DEV_README.md) for setup details.\n\n## License\n\nReleased under the [GNU General Public License v3.0](https://github.com/Comfy-Org/comfy-cli/blob/main/LICENSE).\n\n## Support\n\nQuestions or issues? [Open an issue](https://github.com/Comfy-Org/comfy-cli/issues)\nor reach us on [Discord](https://discord.com/invite/comfyorg).\n\nHappy diffusing with ComfyUI and comfy-cli! 🎉\n"
  },
  {
    "path": "comfy_cli/__init__.py",
    "content": ""
  },
  {
    "path": "comfy_cli/__main__.py",
    "content": "from comfy_cli.cmdline import main\n\nif __name__ == \"__main__\":  # pragma: nocover\n    main()\n"
  },
  {
    "path": "comfy_cli/cmdline.py",
    "content": "import os\nimport subprocess\nimport sys\nimport webbrowser\nfrom typing import Annotated\n\nimport questionary\nimport typer\nfrom rich import print as rprint\nfrom rich.console import Console\n\nfrom comfy_cli import constants, env_checker, logging, tracking, ui, utils\nfrom comfy_cli.command import code_search, custom_nodes, pr_command\nfrom comfy_cli.command import generate as generate_command\nfrom comfy_cli.command import install as install_inner\nfrom comfy_cli.command import run as run_inner\nfrom comfy_cli.command.install import validate_version\nfrom comfy_cli.command.launch import launch as launch_command\nfrom comfy_cli.command.models import models as models_command\nfrom comfy_cli.config_manager import ConfigManager\nfrom comfy_cli.constants import GPU_OPTION, CUDAVersion, ROCmVersion\nfrom comfy_cli.cuda_detect import DEFAULT_CUDA_TAG, detect_cuda_driver_version, resolve_cuda_wheel\nfrom comfy_cli.env_checker import EnvChecker\nfrom comfy_cli.resolve_python import resolve_workspace_python\nfrom comfy_cli.standalone import StandalonePython\nfrom comfy_cli.update import check_for_updates\nfrom comfy_cli.uv import DependencyCompiler\nfrom comfy_cli.workspace_manager import WorkspaceManager, check_comfy_repo\n\nlogging.setup_logging()\napp = typer.Typer()\nworkspace_manager = WorkspaceManager()\n\nconsole = Console()\n\n\ndef main():\n    app()\n\n\nclass MutuallyExclusiveValidator:\n    def __init__(self):\n        self.group = []\n\n    def reset_for_testing(self):\n        self.group.clear()\n\n    def validate(self, _ctx: typer.Context, param: typer.CallbackParam, value: str):\n        # Add cli option to group if it was called with a value\n        if value is not None and param.name not in self.group:\n            self.group.append(param.name)\n        if len(self.group) > 1:\n            raise typer.BadParameter(f\"option `{param.name}` is mutually exclusive with option `{self.group.pop()}`\")\n        return value\n\n\ng_exclusivity = MutuallyExclusiveValidator()\ng_gpu_exclusivity = MutuallyExclusiveValidator()\n\n\n@app.command(help=\"Display help for commands\")\ndef help(ctx: typer.Context):\n    rprint(ctx.find_root().get_help())\n    ctx.exit(0)\n\n\n@app.callback(invoke_without_command=True)\ndef entry(\n    ctx: typer.Context,\n    workspace: Annotated[\n        str | None,\n        typer.Option(\n            show_default=False,\n            help=\"Path to ComfyUI workspace\",\n            callback=g_exclusivity.validate,\n        ),\n    ] = None,\n    recent: Annotated[\n        bool | None,\n        typer.Option(\n            show_default=False,\n            help=\"Execute from recent path\",\n            callback=g_exclusivity.validate,\n        ),\n    ] = None,\n    here: Annotated[\n        bool | None,\n        typer.Option(\n            show_default=False,\n            help=\"Execute from current path\",\n            callback=g_exclusivity.validate,\n        ),\n    ] = None,\n    skip_prompt: Annotated[\n        bool,\n        typer.Option(\n            show_default=False,\n            help=\"Do not prompt user for input, use default options\",\n        ),\n    ] = False,\n    enable_telemetry: Annotated[\n        bool,\n        typer.Option(\n            show_default=False,\n            hidden=True,\n            help=\"Enable tracking\",\n        ),\n    ] = False,\n    version: bool = typer.Option(\n        False,\n        \"--version\",\n        \"-v\",\n        help=\"Print version and exit\",\n    ),\n):\n    if version:\n        rprint(ConfigManager().get_cli_version())\n        ctx.exit(0)\n\n    workspace_manager.setup_workspace_manager(workspace, here, recent, skip_prompt)\n\n    tracking.prompt_tracking_consent(skip_prompt, default_value=enable_telemetry)\n\n    if ctx.invoked_subcommand is None:\n        rprint(\"[bold yellow]Welcome to Comfy CLI![/bold yellow]: https://github.com/Comfy-Org/comfy-cli\")\n        rprint(ctx.get_help())\n        ctx.exit()\n\n    # TODO: Move this to proper place\n    # start_time = time.time()\n    # workspace_manager.scan_dir()\n    # end_time = time.time()\n    #\n    # logging.info(f\"scan_dir took {end_time - start_time:.2f} seconds to run\")\n\n\ndef validate_commit_and_version(commit: str | None, ctx: typer.Context) -> str | None:\n    \"\"\"\n    Validate that the commit is not specified unless the version is 'nightly'.\n    \"\"\"\n    version = ctx.params.get(\"version\")\n    if commit and version != \"nightly\":\n        raise typer.BadParameter(\"You can only specify the commit if the version is 'nightly'.\")\n    return commit\n\n\ndef _resolve_cuda(\n    gpu: GPU_OPTION | None,\n    cuda_version: CUDAVersion | None,\n) -> tuple[CUDAVersion | None, str | None]:\n    \"\"\"Resolve the CUDA wheel tag for an NVIDIA install.\n\n    Returns (cuda_version_enum_or_None, cuda_tag_string_or_None).\n    When the user passed an explicit --cuda-version, that is used as-is.\n    Otherwise auto-detection is attempted.\n    \"\"\"\n    if gpu != GPU_OPTION.NVIDIA:\n        return cuda_version, None\n\n    if cuda_version is not None:\n        tag = f\"cu{cuda_version.value.replace('.', '')}\"\n        rprint(f\"[bold]Using explicit CUDA version:[/bold] {cuda_version.value} ({tag})\")\n        return cuda_version, tag\n\n    drv = detect_cuda_driver_version()\n    if drv is not None:\n        tag = resolve_cuda_wheel(drv)\n        if tag is not None:\n            rprint(f\"[bold green]Detected CUDA driver version:[/bold green] {drv[0]}.{drv[1]} → using {tag}\")\n            return None, tag\n        rprint(\n            f\"[bold yellow]Warning:[/bold yellow] CUDA driver {drv[0]}.{drv[1]} is too old for any known PyTorch wheel. \"\n            f\"Falling back to {DEFAULT_CUDA_TAG}. Use `--cuda-version` to override.\"\n        )\n        return None, DEFAULT_CUDA_TAG\n\n    rprint(\n        f\"[bold yellow]Warning:[/bold yellow] Could not detect CUDA driver version. \"\n        f\"Falling back to {DEFAULT_CUDA_TAG}. Use `--cuda-version` to override.\"\n    )\n    return None, DEFAULT_CUDA_TAG\n\n\n@app.command(help=\"Download and install ComfyUI and ComfyUI-Manager\")\n@tracking.track_command()\ndef install(\n    url: Annotated[\n        str,\n        typer.Option(\n            show_default=False,\n            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\",\n        ),\n    ] = constants.COMFY_GITHUB_URL,\n    version: Annotated[\n        str,\n        typer.Option(\n            show_default=False,\n            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\",\n            callback=validate_version,\n        ),\n    ] = \"nightly\",\n    restore: Annotated[\n        bool,\n        typer.Option(\n            show_default=False,\n            help=\"Restore dependencies for installed ComfyUI if not installed\",\n        ),\n    ] = False,\n    skip_manager: Annotated[\n        bool,\n        typer.Option(show_default=False, help=\"Skip installing the manager component\"),\n    ] = False,\n    skip_torch_or_directml: Annotated[\n        bool,\n        typer.Option(show_default=False, help=\"Skip installing PyTorch Or DirectML\"),\n    ] = False,\n    skip_requirement: Annotated[\n        bool, typer.Option(show_default=False, help=\"Skip installing requirements.txt\")\n    ] = False,\n    nvidia: Annotated[\n        bool | None,\n        typer.Option(\n            show_default=False,\n            help=\"Install for Nvidia gpu\",\n            callback=g_gpu_exclusivity.validate,\n        ),\n    ] = None,\n    cuda_version: Annotated[CUDAVersion | None, typer.Option(show_default=False)] = None,\n    rocm_version: Annotated[ROCmVersion, typer.Option(show_default=True)] = ROCmVersion.v6_3,\n    amd: Annotated[\n        bool | None,\n        typer.Option(\n            show_default=False,\n            help=\"Install for AMD gpu\",\n            callback=g_gpu_exclusivity.validate,\n        ),\n    ] = None,\n    m_series: Annotated[\n        bool | None,\n        typer.Option(\n            show_default=False,\n            help=\"Install for Mac M-Series gpu\",\n            callback=g_gpu_exclusivity.validate,\n        ),\n    ] = None,\n    intel_arc: Annotated[\n        bool | None,\n        typer.Option(\n            hidden=True,\n            show_default=False,\n            help=\"Install for Intel Arc gpu\",\n            callback=g_gpu_exclusivity.validate,\n        ),\n    ] = None,\n    cpu: Annotated[\n        bool | None,\n        typer.Option(\n            show_default=False,\n            help=\"Install for CPU\",\n            callback=g_gpu_exclusivity.validate,\n        ),\n    ] = None,\n    commit: Annotated[\n        str | None, typer.Option(help=\"Specify commit hash for ComfyUI\", callback=validate_commit_and_version)\n    ] = None,\n    fast_deps: Annotated[\n        bool,\n        typer.Option(\n            \"--fast-deps\",\n            show_default=False,\n            help=\"Use uv instead of pip for dependency resolution (comfy-cli built-in resolver)\",\n        ),\n    ] = False,\n    pr: Annotated[\n        str | None,\n        typer.Option(\n            show_default=False,\n            help=\"Install from a specific PR. Supports formats: username:branch, #123, or PR URL\",\n        ),\n    ] = None,\n):\n    check_for_updates()\n    checker = EnvChecker()\n\n    comfy_path, _ = workspace_manager.get_workspace_path()\n\n    is_comfy_installed_at_path, resolved_path = check_comfy_repo(comfy_path)\n    if is_comfy_installed_at_path and not restore:\n        rprint(f\"[bold red]ComfyUI is already installed at the specified path:[/bold red] {comfy_path}\\n\")\n        rprint(\n            \"[bold yellow]If you want to restore dependencies, add the '--restore' option.[/bold yellow]\",\n        )\n        raise typer.Exit(code=1)\n\n    if resolved_path is not None:\n        comfy_path = resolved_path\n\n    if checker.python_version.major < 3 or checker.python_version.minor < 9:\n        rprint(\"[bold red]Python version 3.9 or higher is required to run ComfyUI.[/bold red]\")\n        rprint(f\"You are currently using Python version {env_checker.format_python_version(checker.python_version)}.\")\n    platform = utils.get_os()\n\n    if pr and (version not in {None, \"nightly\"} or commit):\n        rprint(\"--pr cannot be used with --version or --commit\")\n        raise typer.Exit(code=1)\n\n    if cpu:\n        rprint(\"[bold yellow]Installing for CPU[/bold yellow]\")\n        install_inner.execute(\n            url,\n            comfy_path,\n            restore,\n            skip_manager,\n            commit=commit,\n            version=version,\n            gpu=None,\n            cuda_version=cuda_version,\n            cuda_tag=None,\n            rocm_version=rocm_version,\n            plat=platform,\n            skip_torch_or_directml=skip_torch_or_directml,\n            skip_requirement=skip_requirement,\n            fast_deps=fast_deps,\n            pr=pr,\n        )\n        rprint(f\"ComfyUI is installed at: {comfy_path}\")\n        return None\n\n    if nvidia and platform == constants.OS.MACOS:\n        rprint(\"[bold red]Nvidia GPU is never on MacOS. What are you smoking? 🤔[/bold red]\")\n        raise typer.Exit(code=1)\n\n    if platform != constants.OS.MACOS and m_series:\n        rprint(f\"[bold red]You are on {platform} bruh [/bold red]\")\n\n    gpu = None\n\n    if nvidia:\n        gpu = GPU_OPTION.NVIDIA\n    elif amd:\n        gpu = GPU_OPTION.AMD\n    elif m_series:\n        gpu = GPU_OPTION.MAC_M_SERIES\n    elif intel_arc:\n        gpu = GPU_OPTION.INTEL_ARC\n    else:\n        if platform == constants.OS.MACOS:\n            gpu = ui.prompt_select_enum(\n                \"What type of Mac do you have?\",\n                [GPU_OPTION.MAC_M_SERIES, GPU_OPTION.MAC_INTEL],\n            )\n        else:\n            gpu = ui.prompt_select_enum(\n                \"What GPU do you have?\",\n                [GPU_OPTION.NVIDIA, GPU_OPTION.AMD, GPU_OPTION.INTEL_ARC],\n            )\n\n    if gpu is None and not cpu:\n        rprint(\n            \"[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]\"\n        )\n        raise typer.Exit(code=1)\n\n    cuda_version, cuda_tag = _resolve_cuda(gpu, cuda_version) if not skip_torch_or_directml else (cuda_version, None)\n\n    install_inner.execute(\n        url,\n        comfy_path,\n        restore,\n        skip_manager,\n        commit=commit,\n        gpu=gpu,\n        version=version,\n        cuda_version=cuda_version,\n        cuda_tag=cuda_tag,\n        rocm_version=rocm_version,\n        plat=platform,\n        skip_torch_or_directml=skip_torch_or_directml,\n        skip_requirement=skip_requirement,\n        fast_deps=fast_deps,\n        pr=pr,\n    )\n\n    rprint(f\"ComfyUI is installed at: {comfy_path}\")\n\n\n@app.command(help=\"Update ComfyUI Environment [all|comfy]\")\n@tracking.track_command()\ndef update(\n    target: str = typer.Argument(\n        \"comfy\",\n        help=\"[all|comfy]\",\n        autocompletion=utils.create_choice_completer([\"all\", \"comfy\"]),\n    ),\n):\n    if target not in [\"all\", \"comfy\"]:\n        typer.echo(\n            f\"Invalid target: {target}. Allowed targets are 'all', 'comfy'.\",\n            err=True,\n        )\n        raise typer.Exit(code=1)\n\n    comfy_path = workspace_manager.workspace_path\n\n    if \"all\" == target:\n        custom_nodes.command.execute_cm_cli([\"update\", \"all\"])\n    else:\n        rprint(f\"Updating ComfyUI in {comfy_path}...\")\n        if comfy_path is None:\n            rprint(\"ComfyUI path is not found.\")\n            raise typer.Exit(code=1)\n        os.chdir(comfy_path)\n        subprocess.run([\"git\", \"pull\"], check=True)\n        python = resolve_workspace_python(comfy_path)\n        subprocess.run(\n            [python, \"-m\", \"pip\", \"install\", \"-r\", \"requirements.txt\"],\n            check=True,\n        )\n\n    try:\n        custom_nodes.command.update_node_id_cache()\n    except (FileNotFoundError, subprocess.CalledProcessError) as e:\n        rprint(f\"[yellow]Failed to update node id cache: {e}[/yellow]\")\n\n\n@app.command(help=\"Run API workflow file using the ComfyUI launched by `comfy launch --background`\")\n@tracking.track_command()\ndef run(\n    workflow: Annotated[str, typer.Option(help=\"Path to the workflow API json file.\")],\n    wait: Annotated[\n        bool,\n        typer.Option(help=\"If the command should wait until execution completes.\"),\n    ] = True,\n    verbose: Annotated[\n        bool,\n        typer.Option(help=\"Enables verbose output of the execution process.\"),\n    ] = False,\n    host: Annotated[\n        str | None,\n        typer.Option(help=\"The IP/hostname where the ComfyUI instance is running, e.g. 127.0.0.1 or localhost.\"),\n    ] = None,\n    port: Annotated[\n        int | None,\n        typer.Option(help=\"The port where the ComfyUI instance is running, e.g. 8188.\"),\n    ] = None,\n    timeout: Annotated[\n        int | None,\n        typer.Option(help=\"The timeout in seconds for the workflow execution.\"),\n    ] = 30,\n    api_key: Annotated[\n        str | None,\n        typer.Option(\n            \"--api-key\",\n            envvar=\"COMFY_API_KEY\",\n            help=(\n                \"Comfy API key for API Nodes (Partner Nodes). \"\n                \"Embedded in the prompt body as extra_data.api_key_comfy_org on POST /prompt. \"\n                \"For scripting, prefer the COMFY_API_KEY environment variable so the secret \"\n                \"stays out of shell history.\"\n            ),\n        ),\n    ] = None,\n):\n    if api_key:\n        api_key = api_key.strip() or None\n\n    config = ConfigManager()\n\n    if host:\n        s = host.split(\":\")\n        host = s[0]\n        if not port and len(s) == 2:\n            port = int(s[1])\n\n    local_paths = False\n    if config.background:\n        if not host:\n            host = config.background[0]\n            local_paths = True\n        if port:\n            local_paths = False\n        else:\n            port = config.background[1]\n\n    if not host:\n        host = \"127.0.0.1\"\n    if not port:\n        port = 8188\n\n    run_inner.execute(workflow, host, port, wait, verbose, local_paths, timeout, api_key=api_key)\n\n\ndef validate_comfyui(_env_checker):\n    if _env_checker.comfy_repo is None:\n        rprint(\"[bold red]If ComfyUI is not installed, this feature cannot be used.[/bold red]\")\n        raise typer.Exit(code=1)\n\n\n@app.command(help=\"Stop background ComfyUI\")\n@tracking.track_command()\ndef stop():\n    if constants.CONFIG_KEY_BACKGROUND not in ConfigManager().config[\"DEFAULT\"]:\n        rprint(\"[bold red]No ComfyUI is running in the background.[/bold red]\\n\")\n        raise typer.Exit(code=1)\n\n    bg_info = ConfigManager().background\n    if not bg_info:\n        rprint(\"[bold red]No ComfyUI is running in the background.[/bold red]\\n\")\n        raise typer.Exit(code=1)\n    is_killed = utils.kill_all(bg_info[2])\n\n    if not is_killed:\n        rprint(\"[bold red]Failed to stop ComfyUI in the background.[/bold red]\\n\")\n    else:\n        rprint(f\"[bold yellow]Background ComfyUI is stopped.[/bold yellow] ({bg_info[0]}:{bg_info[1]})\")\n\n    ConfigManager().remove_background()\n\n\n@app.command(help=\"Launch ComfyUI: ?[--background] ?[-- <extra args ...>]\")\n@tracking.track_command()\ndef launch(\n    extra: list[str] = typer.Argument(None),\n    background: Annotated[bool, typer.Option(help=\"Launch ComfyUI in background\")] = False,\n    frontend_pr: Annotated[\n        str | None,\n        typer.Option(\n            \"--frontend-pr\",\n            show_default=False,\n            help=\"Use a specific frontend PR. Supports formats: username:branch, #123, or PR URL\",\n        ),\n    ] = None,\n):\n    launch_command(background, extra, frontend_pr)\n\n\n@app.command(\"set-default\", help=\"Set default ComfyUI path\")\n@tracking.track_command()\ndef set_default(\n    workspace_path: str,\n    launch_extras: Annotated[str, typer.Option(help=\"Specify extra options for launch\")] = \"\",\n):\n    comfy_path = os.path.abspath(os.path.expanduser(workspace_path))\n\n    if not os.path.exists(comfy_path):\n        rprint(\n            f\"\\nPath not found: {comfy_path}.\\n\",\n            file=sys.stderr,\n        )\n        raise typer.Exit(code=1)\n\n    is_comfy_repo, resolved_path = check_comfy_repo(comfy_path)\n    if not is_comfy_repo:\n        rprint(\n            f\"\\nSpecified path is not a ComfyUI path: {comfy_path}.\\n\",\n            file=sys.stderr,\n        )\n        raise typer.Exit(code=1)\n\n    comfy_path = resolved_path\n\n    rprint(f\"Specified path is set as default ComfyUI path: {comfy_path} \")\n    workspace_manager.set_default_workspace(comfy_path)\n    workspace_manager.set_default_launch_extras(launch_extras)\n\n\n@app.command(help=\"Show which ComfyUI is selected.\")\n@tracking.track_command()\ndef which():\n    comfy_path = workspace_manager.workspace_path\n    if comfy_path is None:\n        rprint(\n            \"ComfyUI not found, please run 'comfy install', run 'comfy' in a ComfyUI directory, or specify the workspace path with '--workspace'.\"\n        )\n        raise typer.Exit(code=1)\n\n    rprint(f\"Target ComfyUI path: {comfy_path}\")\n\n\n@app.command(help=\"Print out current environment variables.\")\n@tracking.track_command()\ndef env():\n    check_for_updates()\n    env_data = EnvChecker().fill_print_table()\n    workspace_data = workspace_manager.fill_print_table()\n    all_data = env_data + workspace_data\n    ui.display_table(\n        data=all_data,\n        column_names=[\":laptop_computer: Environment\", \"Value\"],\n        title=\"Environment Information\",\n    )\n\n\n@app.command(hidden=True)\n@tracking.track_command()\ndef nodes():\n    rprint(\"\\n[bold red] No such command, did you mean 'comfy node' instead?[/bold red]\\n\")\n\n\n@app.command(hidden=True)\n@tracking.track_command()\ndef models():\n    rprint(\"\\n[bold red] No such command, did you mean 'comfy model' instead?[/bold red]\\n\")\n\n\n@app.command(help=\"Provide feedback on the Comfy CLI tool.\")\n@tracking.track_command()\ndef feedback():\n    rprint(\"Feedback Collection for Comfy CLI Tool\\n\")\n\n    # General Satisfaction\n    general_satisfaction_score = ui.prompt_select(\n        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)\",\n        choices=[\"1\", \"2\", \"3\", \"4\", \"5\"],\n        force_prompting=True,\n    )\n    tracking.track_event(\"feedback_general_satisfaction\", {\"score\": general_satisfaction_score})\n\n    # Usability and User Experience\n    usability_satisfaction_score = ui.prompt_select(\n        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)\",\n        choices=[\"1\", \"2\", \"3\", \"4\", \"5\"],\n        force_prompting=True,\n    )\n    tracking.track_event(\"feedback_usability_satisfaction\", {\"score\": usability_satisfaction_score})\n\n    # Additional Feature-Specific Feedback\n    if questionary.confirm(\"Do you want to provide additional feature-specific feedback on our GitHub page?\").ask():\n        tracking.track_event(\"feedback_additional\")\n        webbrowser.open(\"https://github.com/Comfy-Org/comfy-cli/issues/new/choose\")\n\n    rprint(\"Thank you for your feedback!\")\n\n\n@app.command(hidden=True)\n@app.command(\n    help=\"Given an existing installation of comfy core and any custom nodes, installs any needed python dependencies\"\n)\n@tracking.track_command()\ndef dependency():\n    comfy_path, _ = workspace_manager.get_workspace_path()\n\n    python = resolve_workspace_python(comfy_path)\n    depComp = DependencyCompiler(cwd=comfy_path, executable=python)\n    depComp.compile_deps()\n    depComp.install_deps()\n\n\n@app.command(help=\"Download a standalone Python interpreter and dependencies based on an existing comfyui workspace\")\n@tracking.track_command()\ndef standalone(\n    cli_spec: Annotated[\n        str,\n        typer.Option(\n            show_default=False,\n            help=\"setuptools-style requirement specificer pointing to an instance of comfy-cli\",\n        ),\n    ] = \"comfy-cli\",\n    pack_wheels: Annotated[\n        bool,\n        typer.Option(\n            show_default=False,\n            help=\"Pack requirement wheels in archive when creating standalone bundle\",\n        ),\n    ] = False,\n    platform: Annotated[\n        constants.OS | None,\n        typer.Option(\n            show_default=False,\n            help=\"Create standalone Python for specified platform\",\n        ),\n    ] = None,\n    proc: Annotated[\n        constants.PROC | None,\n        typer.Option(\n            show_default=False,\n            help=\"Create standalone Python for specified processor\",\n        ),\n    ] = None,\n    rehydrate: Annotated[\n        bool,\n        typer.Option(\n            show_default=False,\n            help=\"Create standalone Python for CPU\",\n        ),\n    ] = False,\n):\n    comfy_path, _ = workspace_manager.get_workspace_path()\n\n    platform = utils.get_os() if platform is None else platform\n    proc = utils.get_proc() if proc is None else proc\n\n    if rehydrate:\n        sty = StandalonePython.FromTarball(fpath=\"python.tgz\")\n        sty.rehydrate_comfy_deps(packWheels=pack_wheels)\n    else:\n        sty = StandalonePython.FromDistro(platform=platform, proc=proc)\n        sty.dehydrate_comfy_deps(comfyDir=comfy_path, extraSpecs=[], packWheels=pack_wheels)\n        sty.to_tarball()\n\n\ngenerate_command.register_with(app)\napp.add_typer(models_command.app, name=\"model\", help=\"Manage models.\")\napp.add_typer(custom_nodes.app, name=\"node\", help=\"Manage custom nodes.\")\napp.add_typer(custom_nodes.manager_app, name=\"manager\", help=\"Manage ComfyUI-Manager.\")\n\napp.add_typer(pr_command.app, name=\"pr-cache\", help=\"Manage PR cache.\")\n\napp.add_typer(code_search.app, name=\"code-search\", help=\"Search code across ComfyUI repositories.\")\napp.add_typer(code_search.app, name=\"cs\", hidden=True)\n\napp.add_typer(tracking.app, name=\"tracking\", help=\"Manage analytics tracking settings.\")\n"
  },
  {
    "path": "comfy_cli/command/__init__.py",
    "content": "from . import custom_nodes, install\n\n__all__ = [\"custom_nodes\", \"install\"]\n"
  },
  {
    "path": "comfy_cli/command/code_search.py",
    "content": "\"\"\"CLI commands for searching code across ComfyUI repositories.\"\"\"\n\nimport json\nimport re\nimport sys\nfrom typing import Annotated\nfrom urllib.parse import quote\n\nimport requests\nimport typer\nfrom rich.console import Console\nfrom rich.text import Text\n\nfrom comfy_cli import tracking\n\napp = typer.Typer()\nconsole = Console()\n\nAPI_URL = \"https://comfy-codesearch.vercel.app/api/search/code\"\nDEFAULT_COUNT = 20\nREQUEST_TIMEOUT = 30\n\n\n_TYPE_FILTER_RE = re.compile(r\"(^|\\s)type:\")\n\n\ndef _build_query(query: str, repo: str | None, count: int) -> str:\n    parts = []\n    if repo:\n        if \"/\" not in repo:\n            repo = f\"Comfy-Org/{repo}\"\n        parts.append(f\"repo:^{re.escape(repo)}$\")\n    # Only default to file matches when the user hasn't specified their own\n    # type: filter — otherwise respect whatever they passed (e.g. type:commit).\n    if not _TYPE_FILTER_RE.search(query):\n        parts.append(\"type:file\")\n    parts.append(f\"count:{count}\")\n    parts.append(query)\n    return \" \".join(parts)\n\n\ndef _fetch_results(query: str) -> dict:\n    response = requests.get(API_URL, params={\"query\": query}, timeout=REQUEST_TIMEOUT)\n    response.raise_for_status()\n    return response.json()\n\n\ndef _format_results(search: dict) -> list[dict]:\n    raw_results = search.get(\"results\", {}).get(\"results\", [])\n    formatted = []\n    for result in raw_results:\n        repo_info = result.get(\"repository\") or {}\n        repo_name = repo_info.get(\"name\", \"\")\n        clean_name = repo_name.removeprefix(\"github.com/\")\n\n        file_info = result.get(\"file\") or {}\n        file_path = file_info.get(\"path\", \"\")\n\n        if not clean_name or not file_path:\n            continue\n\n        default_branch = repo_info.get(\"defaultBranch\") or {}\n        branch_name = default_branch.get(\"displayName\", \"main\")\n        commit_hash = (default_branch.get(\"target\") or {}).get(\"commit\", {}).get(\"oid\", \"\")\n        ref = commit_hash or branch_name\n\n        encoded_path = quote(file_path, safe=\"/\")\n        file_url = f\"https://github.com/{clean_name}/blob/{ref}/{encoded_path}\"\n\n        line_matches = result.get(\"lineMatches\") or []\n        matches = []\n        for m in line_matches:\n            line = m.get(\"lineNumber\", 0) + 1\n            preview = m.get(\"preview\", \"\").rstrip()\n            matches.append({\"line\": line, \"preview\": preview, \"url\": f\"{file_url}#L{line}\"})\n\n        formatted.append(\n            {\n                \"repository\": clean_name,\n                \"file\": file_path,\n                \"file_url\": file_url,\n                \"branch\": branch_name,\n                \"commit\": commit_hash,\n                \"matches\": matches,\n            }\n        )\n\n    return formatted\n\n\ndef _get_stats(search: dict) -> dict:\n    return {\n        \"approximate_count\": search.get(\"stats\", {}).get(\"approximateResultCount\", \"0\"),\n        \"match_count\": search.get(\"results\", {}).get(\"matchCount\", 0),\n        \"limit_hit\": search.get(\"results\", {}).get(\"limitHit\", False),\n    }\n\n\ndef _print_results(results: list[dict], stats: dict, json_output: bool) -> None:\n    if json_output:\n        print(json.dumps({\"stats\": stats, \"results\": results}, indent=2))\n        return\n\n    if not results:\n        console.print(\"[yellow]No results found.[/yellow]\")\n        return\n\n    # Use raw isatty() rather than Rich's console.is_terminal: Rich treats\n    # FORCE_COLOR=1 / TTY_COMPATIBLE=1 as terminal-capable even when stdout\n    # is redirected, but OSC 8 escapes in a piped stream defeat the whole\n    # point of this branch (hiding URLs from humans, exposing them to AI).\n    is_tty = sys.stdout.isatty()\n\n    for file_result in results:\n        repo = file_result[\"repository\"]\n        path = file_result[\"file\"]\n        file_url = file_result[\"file_url\"]\n\n        header = Text()\n        if is_tty:\n            # Humans: clickable OSC 8 hyperlink, URL hidden from visible output.\n            header.append(f\"{repo} / {path}\", style=f\"bold cyan link {file_url}\")\n        else:\n            # Non-TTY (pipes, AI agents): print the raw URL once per file so\n            # agents can synthesize #L<line> anchors themselves.\n            header.append(f\"{repo} / {path}\\n\")\n            header.append(f\"  {file_url}\", style=\"dim\")\n        console.print(header)\n\n        for match in file_result[\"matches\"]:\n            line_text = Text(\"  \")\n            line_style = f\"green link {match['url']}\" if is_tty else \"green\"\n            line_text.append(f\"L{match['line']:>5}\", style=line_style)\n            line_text.append(f\"  {match['preview']}\")\n            console.print(line_text)\n\n        console.print()\n\n    limit_msg = \" (limit hit — use --count to fetch more)\" if stats.get(\"limit_hit\") else \"\"\n    console.print(\n        f\"[dim]{stats['approximate_count']} approximate results, {stats['match_count']} matches returned{limit_msg}[/dim]\"\n    )\n\n\n@app.callback(invoke_without_command=True)\n@tracking.track_command()\ndef code_search(\n    query: Annotated[\n        str,\n        typer.Argument(\n            help=(\n                \"Search query (supports Sourcegraph syntax). Defaults to file matches; \"\n                \"pass your own `type:` filter (e.g. `type:commit`) to override.\"\n            ),\n        ),\n    ],\n    repo: Annotated[\n        str | None,\n        typer.Option(\"--repo\", \"-r\", help=\"Filter by repository (e.g. ComfyUI, Comfy-Org/ComfyUI)\"),\n    ] = None,\n    count: Annotated[\n        int,\n        typer.Option(\"--count\", \"-n\", help=\"Maximum number of results\"),\n    ] = DEFAULT_COUNT,\n    json_output: Annotated[\n        bool,\n        typer.Option(\"--json\", \"-j\", help=\"Output results as JSON\"),\n    ] = False,\n):\n    \"\"\"Search code across ComfyUI repositories.\"\"\"\n    built_query = _build_query(query, repo, count)\n\n    try:\n        data = _fetch_results(built_query)\n    except requests.ConnectionError:\n        console.print(\"[bold red]Error: Could not connect to the code search service.[/bold red]\")\n        raise typer.Exit(code=1)\n    except requests.Timeout:\n        console.print(\"[bold red]Error: Request timed out.[/bold red]\")\n        raise typer.Exit(code=1)\n    except requests.HTTPError as e:\n        status = e.response.status_code if e.response is not None else \"unknown\"\n        console.print(f\"[bold red]Error: HTTP {status}[/bold red]\")\n        raise typer.Exit(code=1)\n\n    search = data.get(\"data\", {}).get(\"search\", {})\n    results = _format_results(search)\n    stats = _get_stats(search)\n    _print_results(results, stats, json_output=json_output)\n"
  },
  {
    "path": "comfy_cli/command/custom_nodes/__init__.py",
    "content": "from .command import app, manager_app\n\n__all__ = [\"app\", \"manager_app\"]\n"
  },
  {
    "path": "comfy_cli/command/custom_nodes/bisect_custom_nodes.py",
    "content": "from __future__ import annotations\n\nimport json\nimport os\nfrom pathlib import Path\nfrom typing import Annotated, Literal, NamedTuple\n\nimport typer\n\nfrom comfy_cli.command.custom_nodes.cm_cli_util import execute_cm_cli\nfrom comfy_cli.command.launch import launch as launch_command\n\nbisect_app = typer.Typer()\n\n# File to store the state of bisect\ndefault_state_file = Path(\"bisect_state.json\")\n\n\nclass BisectState(NamedTuple):\n    status: Literal[\"idle\", \"running\", \"resolved\"]\n\n    # All nodes in the current bisect session\n    all: list[str]\n\n    # The range of nodes that contains the bad node\n    range: list[str]\n\n    # The active set of nodes to test\n    active: list[str]\n\n    # The arguments to pass to the ComfyUI launch command\n    launch_args: list[str] = []\n\n    def good(self) -> BisectState:\n        \"\"\"The active set of nodes is good, narrowing down the potential problem area.\"\"\"\n        if self.status != \"running\":\n            raise ValueError(\"No bisect session running.\")\n\n        new_range = list(set(self.range) - set(self.active))\n\n        if len(new_range) == 1:\n            return BisectState(\n                status=\"resolved\",\n                all=self.all,\n                launch_args=self.launch_args,\n                range=new_range,\n                active=[],\n            )\n\n        return BisectState(\n            status=\"running\",\n            all=self.all,\n            launch_args=self.launch_args,\n            range=new_range,\n            active=new_range[len(new_range) // 2 :],\n        )\n\n    def bad(self) -> BisectState:\n        \"\"\"The active set of nodes is bad, indicating the problem is within this set.\"\"\"\n        if self.status != \"running\":\n            raise ValueError(\"No bisect session running.\")\n\n        new_range = self.active\n\n        if len(new_range) == 1:\n            return BisectState(\n                status=\"resolved\",\n                all=self.all,\n                launch_args=self.launch_args,\n                range=new_range,\n                active=[],\n            )\n\n        return BisectState(\n            status=\"running\",\n            all=self.all,\n            launch_args=self.launch_args,\n            range=new_range,\n            active=new_range[len(new_range) // 2 :],\n        )\n\n    def save(self, state_file=None):\n        self.set_custom_node_enabled_states()\n        state_file = state_file or default_state_file\n        with state_file.open(\"w\") as f:\n            json.dump(self._asdict(), f)  # pylint: disable=no-member\n\n    def reset(self):\n        BisectState(\n            \"idle\",\n            all=self.all,\n            launch_args=self.launch_args,\n            range=self.all,\n            active=self.all,\n        ).set_custom_node_enabled_states()\n        return BisectState(\"idle\", self.all, self.all, self.all, self.launch_args)\n\n    @classmethod\n    def load(cls, state_file=None) -> BisectState:\n        state_file = state_file or default_state_file\n        if state_file.exists():\n            with state_file.open() as f:\n                return BisectState(**json.load(f))\n        return BisectState(\"idle\", [], [], [])\n\n    @property\n    def inactive_nodes(self) -> list[str]:\n        return list(set(self.all) - set(self.active))\n\n    def set_custom_node_enabled_states(self):\n        if self.active:\n            execute_cm_cli([\"enable\", *self.active])\n        if self.inactive_nodes:\n            execute_cm_cli([\"disable\", *self.inactive_nodes])\n\n    def __str__(self):\n        active_list = \"\\n\".join([f\"{i + 1:3}. {node}\" for i, node in enumerate(self.active)])\n        return f\"\"\"BisectState(status={self.status})\nset of nodes with culprit: {len(self.range)}\nset of nodes to test: {len(self.active)}\n--------------------------\n{active_list}\"\"\"\n\n\ndef parse_cm_output(cm_output: str, pinned_nodes: set[str] | None = None) -> list[str]:\n    \"\"\"Parse cm_cli simple-show output into a list of node names.\n\n    cm_cli simple-show always formats node entries as ``name@version``\n    (see ComfyUI-Manager cm_cli show_list).  We whitelist on the ``@``\n    separator so any progress/status lines are ignored regardless of\n    their prefix.\n    \"\"\"\n    pinned = pinned_nodes or set()\n    return [\n        stripped\n        for line in cm_output.strip().split(\"\\n\")\n        if (stripped := line.strip()) and \"@\" in stripped and stripped not in pinned\n    ]\n\n\n@bisect_app.command(\n    help=\"Start a new bisect session with optionally pinned nodes to always enable, and optional ComfyUI launch args.\"\n    + \"?[--pinned-nodes PINNED_NODES]\"\n    + \"?[-- <extra args ...>]\"\n)\ndef start(\n    pinned_nodes: Annotated[str, typer.Option(help=\"Pinned nodes always enable during the bisect\")] = \"\",\n    extra: list[str] = typer.Argument(None),\n):\n    \"\"\"Start a new bisect session. The initial state is bad with all custom nodes\n    enabled, good with all custom nodes disabled.\"\"\"\n\n    if BisectState.load().status != \"idle\":\n        typer.echo(\"A bisect session is already running.\")\n        raise typer.Exit()\n\n    pinned_nodes = {s.strip() for s in pinned_nodes.split(\",\") if s}\n\n    cm_output: str | None = execute_cm_cli([\"simple-show\", \"enabled\"])\n    if cm_output is None:\n        typer.echo(\"Failed to fetch the list of nodes.\")\n        raise typer.Exit()\n\n    nodes_list = parse_cm_output(cm_output, pinned_nodes)\n    state = BisectState(\n        status=\"running\",\n        all=nodes_list,\n        range=nodes_list,\n        active=nodes_list,\n        launch_args=extra or [],\n    )\n    state.save()\n\n    typer.echo(f\"Bisect session started.\\n{state}\")\n    if pinned_nodes:\n        typer.echo(f\"Pinned nodes: {', '.join(pinned_nodes)}\")\n\n    bad()\n\n\n@bisect_app.command(help=\"Mark the current active set as good, indicating the problem is outside the test set.\")\ndef good():\n    state = BisectState.load()\n    if state.status != \"running\":\n        typer.echo(\"No bisect session running or no active nodes to process.\")\n        raise typer.Exit()\n\n    new_state = state.good()\n\n    if new_state.status == \"resolved\":\n        assert len(new_state.range) == 1\n        typer.echo(f\"Problematic node identified: {new_state.range[0]}\")\n        reset()\n    else:\n        new_state.save()\n        typer.echo(new_state)\n        launch_command(background=False, extra=state.launch_args)\n\n\n@bisect_app.command(help=\"Mark the current active set as bad, indicating the problem is within the test set.\")\ndef bad():\n    state = BisectState.load()\n    if state.status != \"running\":\n        typer.echo(\"No bisect session running or no active nodes to process.\")\n        raise typer.Exit()\n\n    new_state = state.bad()\n\n    if new_state.status == \"resolved\":\n        assert len(new_state.range) == 1\n        typer.echo(f\"Problematic node identified: {new_state.range[0]}\")\n        reset()\n    else:\n        new_state.save()\n        typer.echo(new_state)\n        launch_command(background=False, extra=state.launch_args)\n\n\n@bisect_app.command(help=\"Reset the current bisect session.\")\ndef reset():\n    if default_state_file.exists():\n        BisectState.load().reset()\n        os.unlink(default_state_file)\n        typer.echo(\"Bisect session reset.\")\n    else:\n        typer.echo(\"No bisect session to reset.\")\n"
  },
  {
    "path": "comfy_cli/command/custom_nodes/cm_cli_util.py",
    "content": "from __future__ import annotations\n\nimport importlib.util\nimport os\nimport subprocess\nimport sys\nimport threading\nimport uuid\nfrom functools import lru_cache\n\nimport typer\nfrom rich import print\n\nfrom comfy_cli.config_manager import ConfigManager\nfrom comfy_cli.resolve_python import resolve_workspace_python\nfrom comfy_cli.uv import DependencyCompiler\nfrom comfy_cli.workspace_manager import WorkspaceManager, check_comfy_repo\n\nworkspace_manager = WorkspaceManager()\n\n# set of commands that invalidate (ie require an update of) dependencies after they are run\n_dependency_cmds = {\n    \"install\",\n    \"reinstall\",\n}\n\n\n@lru_cache(maxsize=1)\ndef find_cm_cli() -> bool:\n    \"\"\"Check if cm_cli module is available in the workspace Python.\n\n    First checks the workspace venv Python (primary path — matches the Python\n    used by execute_cm_cli). Falls back to the current Python environment only\n    when the workspace Python is the same as sys.executable.\n\n    Results are cached for the session lifetime.\n    \"\"\"\n    ws = workspace_manager.workspace_path\n    if ws:\n        python = resolve_workspace_python(ws)\n        if python != sys.executable:\n            # Workspace uses a different Python — check that one\n            try:\n                result = subprocess.run(\n                    [python, \"-c\", \"import cm_cli\"],\n                    capture_output=True,\n                    timeout=10,\n                )\n                return result.returncode == 0\n            except (subprocess.TimeoutExpired, OSError):\n                return False\n\n    # Same Python or no workspace — check current environment\n    return importlib.util.find_spec(\"cm_cli\") is not None\n\n\ndef resolve_manager_gui_mode(not_installed_value: str | None = None) -> str | None:\n    \"\"\"Resolve manager GUI mode from config, with legacy migration.\n\n    Priority: CONFIG_KEY_MANAGER_GUI_MODE > CONFIG_KEY_MANAGER_GUI_ENABLED > auto-detect.\n\n    Args:\n        not_installed_value: Value to return when manager is not installed and no config exists.\n            Callers use None (launch — means \"no flags\") or \"not-installed\" (display).\n    \"\"\"\n    from comfy_cli import constants\n\n    config_manager = ConfigManager()\n    mode = config_manager.get(constants.CONFIG_KEY_MANAGER_GUI_MODE)\n\n    if mode is not None:\n        return mode\n\n    # Legacy migration\n    old_value = config_manager.get(constants.CONFIG_KEY_MANAGER_GUI_ENABLED)\n    if old_value is not None:\n        old_str = str(old_value).lower()\n        if old_str in (\"false\", \"0\", \"off\"):\n            return \"disable\"\n        if old_str in (\"true\", \"1\", \"on\"):\n            return \"enable-gui\"\n\n    # No config at all — check manager availability\n    if not find_cm_cli():\n        return not_installed_value\n    return \"enable-gui\"\n\n\ndef execute_cm_cli(\n    args, channel=None, fast_deps=False, no_deps=False, uv_compile=False, mode=None, raise_on_error=False\n) -> str | None:\n    _config_manager = ConfigManager()\n\n    workspace_path = workspace_manager.workspace_path\n\n    if not workspace_path:\n        print(\"\\n[bold red]ComfyUI path is not resolved.[/bold red]\\n\", file=sys.stderr)\n        raise typer.Exit(code=1)\n\n    if not check_comfy_repo(workspace_path)[0]:\n        print(\n            f\"\\n[bold red]'{workspace_path}' is not a valid ComfyUI workspace.[/bold red]\\n\"\n            \"Run [bold]comfy install[/bold] to set up ComfyUI, or use [bold]--workspace <path>[/bold] to specify a valid path.\\n\",\n            file=sys.stderr,\n        )\n        raise typer.Exit(code=1)\n\n    if not find_cm_cli():\n        print(\n            \"\\n[bold red]ComfyUI-Manager not found. 'cm-cli' command is not available.[/bold red]\\n\",\n            file=sys.stderr,\n        )\n        raise typer.Exit(code=1)\n\n    python = resolve_workspace_python(workspace_path)\n    cmd = [python, \"-m\", \"cm_cli\"] + args\n\n    if channel is not None:\n        cmd += [\"--channel\", channel]\n\n    if uv_compile:\n        cmd += [\"--uv-compile\"]\n    elif fast_deps or no_deps:\n        cmd += [\"--no-deps\"]\n\n    if mode is not None:\n        cmd += [\"--mode\", mode]\n\n    new_env = os.environ.copy()\n    session_path = os.path.join(_config_manager.get_config_path(), \"tmp\", str(uuid.uuid4()))\n    new_env[\"__COMFY_CLI_SESSION__\"] = session_path\n    new_env[\"COMFYUI_PATH\"] = workspace_path\n    new_env[\"PYTHONUNBUFFERED\"] = \"1\"\n\n    print(f\"Execute from: {workspace_path}\")\n    print(f\"Command: {cmd}\")\n    try:\n        process = subprocess.Popen(\n            cmd,\n            env=new_env,\n            stdout=subprocess.PIPE,\n            stderr=subprocess.PIPE,\n            text=True,\n            encoding=\"utf-8\",\n            errors=\"replace\",\n        )\n\n        # Read stderr in a background thread to avoid pipe deadlock on Windows.\n        # Windows pipe buffers are small (4 KB); if stderr fills up while the main\n        # thread is blocked reading stdout line-by-line, the child process blocks\n        # on stderr writes and never closes stdout — classic deadlock.\n        stderr_lines: list[str] = []\n\n        def _drain_stderr():\n            for line in process.stderr:\n                sys.stderr.write(line)\n                sys.stderr.flush()\n                stderr_lines.append(line)\n\n        stderr_thread = threading.Thread(target=_drain_stderr, daemon=True)\n        stderr_thread.start()\n\n        stdout_lines = []\n        for line in process.stdout:\n            sys.stdout.write(line)\n            sys.stdout.flush()\n            stdout_lines.append(line)\n\n        stderr_thread.join(timeout=10)\n        return_code = process.wait()\n        stdout_output = \"\".join(stdout_lines)\n        stderr_output = \"\".join(stderr_lines)\n        if return_code != 0:\n            raise subprocess.CalledProcessError(return_code, cmd, output=stdout_output, stderr=stderr_output)\n\n        if fast_deps and args[0] in _dependency_cmds:\n            # we're using the fast_deps behavior and just ran a command that invalidated the dependencies\n            depComp = DependencyCompiler(cwd=workspace_path, executable=python)\n            depComp.compile_deps()\n            depComp.install_deps()\n\n        workspace_manager.set_recent_workspace(workspace_path)\n        return stdout_output\n    except subprocess.CalledProcessError as e:\n        if raise_on_error:\n            raise e\n\n        if e.returncode == 1:\n            print(f\"\\n[bold red]Execution error: {cmd}[/bold red]\\n\", file=sys.stderr)\n            return None\n\n        if e.returncode == 2:\n            return None\n\n        raise e\n"
  },
  {
    "path": "comfy_cli/command/custom_nodes/command.py",
    "content": "import os\nimport pathlib\nimport platform\nimport shutil\nimport subprocess\nimport sys\nimport uuid\nfrom enum import Enum\nfrom typing import Annotated\n\nimport typer\nfrom rich import print\nfrom rich.console import Console\n\nfrom comfy_cli import constants, logging, tracking, ui, utils\nfrom comfy_cli.command.custom_nodes.bisect_custom_nodes import bisect_app\nfrom comfy_cli.command.custom_nodes.cm_cli_util import execute_cm_cli, find_cm_cli\nfrom comfy_cli.config_manager import ConfigManager\nfrom comfy_cli.constants import NODE_ZIP_FILENAME\nfrom comfy_cli.file_utils import (\n    DownloadException,\n    download_file,\n    extract_package_as_zip,\n    upload_file_to_signed_url,\n    zip_files,\n)\nfrom comfy_cli.registry import (\n    RegistryAPI,\n    extract_node_configuration,\n    initialize_project_config,\n)\nfrom comfy_cli.resolve_python import resolve_workspace_python\nfrom comfy_cli.workspace_manager import WorkspaceManager\n\nconsole = Console()\napp = typer.Typer()\napp.add_typer(bisect_app, name=\"bisect\", help=\"Bisect custom nodes for culprit node.\")\nmanager_app = typer.Typer()\nworkspace_manager = WorkspaceManager()\nregistry_api = RegistryAPI()\n\n\n# Enum for show command target\nclass ShowTarget(str, Enum):\n    INSTALLED = \"installed\"\n    ENABLED = \"enabled\"\n    NOT_INSTALLED = \"not-installed\"\n    DISABLED = \"disabled\"\n    ALL = \"all\"\n    SNAPSHOT = \"snapshot\"\n    SNAPSHOT_LIST = \"snapshot-list\"\n\n\ndef _resolve_uv_compile(uv_compile: bool | None, fast_deps: bool = False, no_deps: bool = False) -> bool:\n    \"\"\"Resolve effective uv_compile value from explicit flag, config default, and conflicting flags.\n\n    Priority: explicit --uv-compile/--no-uv-compile > config default > False.\n    When config default is True, explicit --fast-deps or --no-deps silently override it.\n    \"\"\"\n    if uv_compile is not None:\n        return uv_compile\n\n    config_manager = ConfigManager()\n    config_value = config_manager.get(constants.CONFIG_KEY_UV_COMPILE_DEFAULT)\n    if config_value is not None and config_value.lower() == \"true\":\n        if fast_deps or no_deps:\n            return False\n        return True\n    return False\n\n\ndef validate_comfyui_manager():\n    if not find_cm_cli():\n        print(\"[bold red]ComfyUI-Manager is not installed. 'cm-cli' command is not available.[/bold red]\")\n        raise typer.Exit(code=1)\n\n\ndef run_script(cmd, cwd=\".\"):\n    if len(cmd) > 0 and cmd[0].startswith(\"#\"):\n        print(f\"[ComfyUI-Manager] Unexpected behavior: `{cmd}`\")\n        return 0\n\n    subprocess.check_call(cmd, cwd=cwd)\n\n    return 0\n\n\npip_map = None\n\n\ndef get_installed_packages():\n    global pip_map\n\n    if pip_map is None:\n        try:\n            python = resolve_workspace_python(workspace_manager.workspace_path)\n            result = subprocess.check_output([python, \"-m\", \"pip\", \"list\"], universal_newlines=True)\n\n            pip_map = {}\n            for line in result.split(\"\\n\"):\n                x = line.strip()\n                if x:\n                    y = line.split()\n                    if y[0] == \"Package\" or y[0].startswith(\"-\"):\n                        continue\n\n                    pip_map[y[0]] = y[1]\n        except subprocess.CalledProcessError:\n            print(\"[ComfyUI-Manager] Failed to retrieve the information of installed pip packages.\")\n            return set()\n\n    return pip_map\n\n\ndef try_install_script(repo_path, install_cmd, instant_execution=False):\n    startup_script_path = os.path.join(workspace_manager.workspace_path, \"startup-scripts\")\n    if not instant_execution and (\n        (len(install_cmd) > 0 and install_cmd[0].startswith(\"#\"))\n        or (\n            platform.system() == \"Windows\"\n            # From Yoland: disable commit compare\n            # and comfy_ui_commit_datetime.date()\n            # >= comfy_ui_required_commit_datetime.date()\n        )\n    ):\n        if not os.path.exists(startup_script_path):\n            os.makedirs(startup_script_path)\n\n        script_path = os.path.join(startup_script_path, \"install-scripts.txt\")\n        with open(script_path, \"a\", encoding=\"utf-8\") as file:\n            obj = [repo_path] + install_cmd\n            file.write(f\"{obj}\\n\")\n\n        return True\n    else:\n        # From Yoland: Disable blacklisting\n        # if len(install_cmd) == 5 and install_cmd[2:4] == ['pip', 'install']:\n        #     if is_blacklisted(install_cmd[4]):\n        #         print(f\"[ComfyUI-Manager] skip black listed pip installation: '{install_cmd[4]}'\")\n        #         return True\n\n        print(f\"\\n## ComfyUI-Manager: EXECUTE => {install_cmd}\")\n        code = run_script(install_cmd, cwd=repo_path)\n\n        # From Yoland: Disable warning\n        # if platform.system() != \"Windows\":\n        #     try:\n        #         if comfy_ui_commit_datetime.date() < comfy_ui_required_commit_datetime.date():\n        #             print(\"\\n\\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.\")\n        #             print(f\"[WARN] The extension installation feature may not work properly in the current installed ComfyUI version on Windows environment.\")\n        #             print(\"###################################################################\\n\\n\")\n        #     except:\n        #         pass\n\n        if code != 0:\n            print(\"install script failed\")\n            return False\n\n\ndef execute_install_script(repo_path):\n    install_script_path = os.path.join(repo_path, \"install.py\")\n    requirements_path = os.path.join(repo_path, \"requirements.txt\")\n\n    # From Yoland: disable lazy mode\n    # if lazy_mode:\n    #     install_cmd = [\"#LAZY-INSTALL-SCRIPT\",  sys.executable]\n    #     try_install_script(repo_path, install_cmd)\n    # else:\n\n    if os.path.exists(requirements_path):\n        print(\"Install: pip packages\")\n        python = resolve_workspace_python(workspace_manager.workspace_path)\n        # Absolute path so pip doesn't re-resolve it against cwd=repo_path\n        # in try_install_script, which would double the path if repo_path\n        # is relative.\n        install_cmd = [python, \"-m\", \"pip\", \"install\", \"-r\", os.path.abspath(requirements_path)]\n        try_install_script(repo_path, install_cmd)\n\n    if os.path.exists(install_script_path):\n        print(\"Install: install script\")\n        python = resolve_workspace_python(workspace_manager.workspace_path)\n        install_cmd = [python, \"install.py\"]\n        try_install_script(repo_path, install_cmd)\n\n\n@app.command(\"save-snapshot\", help=\"Save a snapshot of the current ComfyUI environment\")\n@tracking.track_command(\"node\")\ndef save_snapshot(\n    output: Annotated[\n        str | None,\n        typer.Option(show_default=False, help=\"Specify the output file path. (.json/.yaml)\"),\n    ] = None,\n):\n    if output is None:\n        execute_cm_cli([\"save-snapshot\"])\n    else:\n        output = os.path.abspath(output)  # to compensate chdir\n        execute_cm_cli([\"save-snapshot\", \"--output\", output])\n\n\n@app.command(\"restore-snapshot\", help=\"Restore snapshot from snapshot file\")\n@tracking.track_command(\"node\")\ndef restore_snapshot(\n    path: str,\n    pip_non_url: bool | None = typer.Option(\n        default=None,\n        show_default=False,\n        help=\"Restore for pip packages registered on PyPI.\",\n    ),\n    pip_non_local_url: bool | None = typer.Option(\n        default=None,\n        show_default=False,\n        help=\"Restore for pip packages registered at web URLs.\",\n    ),\n    pip_local_url: bool | None = typer.Option(\n        default=None,\n        show_default=False,\n        help=\"Restore for pip packages specified by local paths.\",\n    ),\n    uv_compile: Annotated[\n        bool | None,\n        typer.Option(\n            \"--uv-compile/--no-uv-compile\",\n            show_default=False,\n            help=\"After restoring, batch-resolve all dependencies via uv pip compile (requires ComfyUI-Manager v4.1+)\",\n        ),\n    ] = None,\n):\n    extras = []\n\n    if pip_non_url:\n        extras += [\"--pip-non-url\"]\n\n    if pip_non_local_url:\n        extras += [\"--pip-non-local-url\"]\n\n    if pip_local_url:\n        extras += [\"--pip-local-url\"]\n\n    path = os.path.abspath(path)\n    execute_cm_cli([\"restore-snapshot\", path] + extras, uv_compile=_resolve_uv_compile(uv_compile))\n\n\n@app.command(\"restore-dependencies\", help=\"Restore dependencies from installed custom nodes\")\n@tracking.track_command(\"node\")\ndef restore_dependencies(\n    uv_compile: Annotated[\n        bool | None,\n        typer.Option(\n            \"--uv-compile/--no-uv-compile\",\n            show_default=False,\n            help=\"After restoring, batch-resolve all dependencies via uv pip compile (requires ComfyUI-Manager v4.1+)\",\n        ),\n    ] = None,\n):\n    execute_cm_cli([\"restore-dependencies\"], uv_compile=_resolve_uv_compile(uv_compile))\n\n\n@manager_app.command(\"disable\", help=\"Disable ComfyUI-Manager completely\")\n@tracking.track_command(\"node\")\ndef disable_manager():\n    \"\"\"Disable ComfyUI-Manager. No manager flags will be passed to ComfyUI.\"\"\"\n    config_manager = ConfigManager()\n    config_manager.set(constants.CONFIG_KEY_MANAGER_GUI_MODE, \"disable\")\n    print(\"[bold yellow]ComfyUI-Manager has been disabled.[/bold yellow]\")\n    print(\"No manager flags will be passed to ComfyUI on next launch.\")\n\n\n@manager_app.command(\"enable-gui\", help=\"Enable ComfyUI-Manager with new GUI\")\n@tracking.track_command(\"node\")\ndef enable_gui():\n    \"\"\"Enable ComfyUI-Manager with new GUI.\"\"\"\n    config_manager = ConfigManager()\n    config_manager.set(constants.CONFIG_KEY_MANAGER_GUI_MODE, \"enable-gui\")\n    print(\"[bold green]ComfyUI-Manager GUI has been enabled.[/bold green]\")\n    print(\"[dim]ComfyUI will launch with: --enable-manager[/dim]\")\n\n\n@manager_app.command(\"disable-gui\", help=\"Enable ComfyUI-Manager without GUI\")\n@tracking.track_command(\"node\")\ndef disable_gui():\n    \"\"\"Enable ComfyUI-Manager but disable its GUI.\"\"\"\n    config_manager = ConfigManager()\n    config_manager.set(constants.CONFIG_KEY_MANAGER_GUI_MODE, \"disable-gui\")\n    print(\"[bold green]ComfyUI-Manager enabled with GUI disabled.[/bold green]\")\n    print(\"[dim]ComfyUI will launch with: --enable-manager --disable-manager-ui[/dim]\")\n\n\n@manager_app.command(\"enable-legacy-gui\", help=\"Enable ComfyUI-Manager with legacy GUI\")\n@tracking.track_command(\"node\")\ndef enable_legacy_gui():\n    \"\"\"Enable ComfyUI-Manager with legacy GUI.\"\"\"\n    config_manager = ConfigManager()\n    config_manager.set(constants.CONFIG_KEY_MANAGER_GUI_MODE, \"enable-legacy-gui\")\n    print(\"[bold green]ComfyUI-Manager legacy GUI has been enabled.[/bold green]\")\n    print(\"[dim]ComfyUI will launch with: --enable-manager --enable-manager-legacy-ui[/dim]\")\n\n\n@manager_app.command(\"migrate-legacy\", help=\"Migrate legacy git-cloned ComfyUI-Manager to .disabled\")\n@tracking.track_command(\"node\")\ndef migrate_legacy(\n    yes: Annotated[\n        bool,\n        typer.Option(\"--yes\", \"-y\", help=\"Skip confirmation prompt\"),\n    ] = False,\n):\n    \"\"\"\n    Migrate legacy ComfyUI-Manager from custom_nodes/ to custom_nodes/.disabled/\n\n    Detects .enable-cli-only-mode file to set appropriate mode:\n    - If .enable-cli-only-mode exists → mode = disable\n    - Otherwise → mode = enable-gui\n    \"\"\"\n    if not workspace_manager.workspace_path:\n        print(\"[bold red]ComfyUI workspace is not set.[/bold red]\")\n        print(\"[dim]Use --workspace or run from a ComfyUI directory.[/dim]\")\n        raise typer.Exit(code=1)\n\n    custom_nodes_path = pathlib.Path(workspace_manager.workspace_path) / \"custom_nodes\"\n\n    # Find legacy manager with case-insensitive matching (must be a real directory, not symlink)\n    legacy_manager_path = None\n    if custom_nodes_path.exists():\n        for item in custom_nodes_path.iterdir():\n            if item.is_dir() and not item.is_symlink() and item.name.lower() == \"comfyui-manager\":\n                legacy_manager_path = item\n                break\n\n    # Check if legacy manager exists\n    if legacy_manager_path is None:\n        print(\"[bold yellow]No legacy ComfyUI-Manager found in custom_nodes/[/bold yellow]\")\n        print(\"Nothing to migrate.\")\n        return\n\n    # Verify it's a git-cloned repository\n    git_dir = legacy_manager_path / \".git\"\n    if not git_dir.exists():\n        print(f\"[bold yellow]Warning: {legacy_manager_path.name} does not appear to be a git repository.[/bold yellow]\")\n        print(\"[dim]Expected a git-cloned ComfyUI-Manager. Skipping migration.[/dim]\")\n        return\n\n    # Detect CLI-only mode before any changes\n    cli_only_mode_file = legacy_manager_path / \".enable-cli-only-mode\"\n    cli_only_mode = cli_only_mode_file.exists()\n\n    # Show what will happen and ask for confirmation\n    print(f\"[bold]Found legacy ComfyUI-Manager:[/bold] {legacy_manager_path}\")\n    print(f\"[dim]CLI-only mode: {cli_only_mode}[/dim]\")\n    print()\n    print(\"[bold]This will:[/bold]\")\n    print(f\"  1. Move {legacy_manager_path.name} to custom_nodes/.disabled/\")\n    print(f\"  2. Set manager mode to: {'disable' if cli_only_mode else 'enable-gui'}\")\n    print(\"  3. Install manager_requirements.txt (if present)\")\n    print()\n\n    if not yes:\n        confirm = ui.prompt_confirm_action(\"Proceed with migration?\", False)\n        if not confirm:\n            print(\"[dim]Migration cancelled.[/dim]\")\n            return\n\n    # Create .disabled directory\n    disabled_path = custom_nodes_path / \".disabled\"\n    disabled_path.mkdir(exist_ok=True)\n\n    # Check if target already exists (case-insensitive)\n    existing_target = None\n    for item in disabled_path.iterdir():\n        if item.is_dir() and item.name.lower() == \"comfyui-manager\":\n            existing_target = item\n            break\n\n    if existing_target is not None:\n        print(f\"[bold red]Target path already exists: {existing_target}[/bold red]\")\n        print(\"Please remove it manually and try again.\")\n        raise typer.Exit(code=1)\n\n    # Move legacy manager (preserve original directory name)\n    target_path = disabled_path / legacy_manager_path.name\n    try:\n        shutil.move(str(legacy_manager_path), str(target_path))\n    except OSError as e:\n        print(f\"[bold red]Failed to move legacy manager: {e}[/bold red]\")\n        raise typer.Exit(code=1)\n\n    # Install manager_requirements.txt if present\n    workspace_path = pathlib.Path(workspace_manager.workspace_path)\n    manager_req_path = workspace_path / constants.MANAGER_REQUIREMENTS_FILE\n    python = resolve_workspace_python(str(workspace_path))\n    install_success = False  # Default to failure, set True only on success\n    if manager_req_path.exists():\n        print(\"[dim]Installing ComfyUI-Manager dependencies...[/dim]\")\n        result = subprocess.run(\n            [python, \"-m\", \"pip\", \"install\", \"-r\", str(manager_req_path)],\n            check=False,\n        )\n        if result.returncode != 0:\n            print(\"[bold yellow]Warning: Failed to install ComfyUI-Manager dependencies.[/bold yellow]\")\n            print(\"[dim]You may need to run: pip install -r manager_requirements.txt[/dim]\")\n        else:\n            install_success = True\n    else:\n        print(\"[bold yellow]Warning: manager_requirements.txt not found (older ComfyUI version?).[/bold yellow]\")\n        print(\"[dim]ComfyUI-Manager pip package not installed.[/dim]\")\n\n    # Set config mode\n    config_manager = ConfigManager()\n    if cli_only_mode or not install_success:\n        config_manager.set(constants.CONFIG_KEY_MANAGER_GUI_MODE, \"disable\")\n        print(\"[bold green]Legacy ComfyUI-Manager migrated to .disabled/[/bold green]\")\n        if cli_only_mode:\n            print(\"[dim]Detected .enable-cli-only-mode → Manager set to: disable[/dim]\")\n        else:\n            print(\"[dim]Manager installation failed → Manager set to: disable[/dim]\")\n            print(\"[dim]After fixing installation, run: comfy manager enable-gui[/dim]\")\n    else:\n        config_manager.set(constants.CONFIG_KEY_MANAGER_GUI_MODE, \"enable-gui\")\n        print(\"[bold green]Legacy ComfyUI-Manager migrated to .disabled/[/bold green]\")\n        print(\"[dim]Manager set to: enable-gui (new GUI)[/dim]\")\n\n    print(\"\\n[bold]The new pip-installed ComfyUI-Manager will be used on next launch.[/bold]\")\n\n\n@manager_app.command(\n    \"uv-compile-default\", help=\"Set whether --uv-compile is used by default for custom node operations\"\n)\n@tracking.track_command(\"node\")\ndef uv_compile_default(\n    enabled: Annotated[\n        bool,\n        typer.Argument(help=\"true to enable, false to disable\"),\n    ],\n):\n    config_manager = ConfigManager()\n    config_manager.set(constants.CONFIG_KEY_UV_COMPILE_DEFAULT, str(enabled))\n    if enabled:\n        print(\"[bold green]uv-compile is now enabled by default.[/bold green]\")\n        print(\"[dim]Use --no-uv-compile to override for individual commands.[/dim]\")\n    else:\n        print(\"[bold yellow]uv-compile default has been disabled.[/bold yellow]\")\n        print(\"[dim]Use --uv-compile to enable for individual commands.[/dim]\")\n\n\n@manager_app.command(help=\"Clear reserved startup action in ComfyUI-Manager\")\n@tracking.track_command(\"node\")\ndef clear():\n    execute_cm_cli([\"clear\"])\n\n\n@app.command(\"update-cache\", help=\"Force-fetch remote data and populate local Manager cache (blocking)\")\n@tracking.track_command(\"node\")\ndef update_cache():\n    execute_cm_cli([\"update-cache\"])\n\n\n# completers\nmode_completer = utils.create_choice_completer([\"remote\", \"local\", \"cache\"])\n\n\nchannel_completer = utils.create_choice_completer([\"default\", \"recent\", \"dev\", \"forked\", \"tutorial\", \"legacy\"])\n\n\ndef node_completer(incomplete: str) -> list[str]:\n    try:\n        config_manager = ConfigManager()\n        tmp_path = os.path.join(config_manager.get_config_path(), \"tmp\", \"node-cache.list\")\n\n        with open(tmp_path, encoding=\"UTF-8\", errors=\"ignore\") as cache_file:\n            return [node_id for node_id in cache_file.readlines() if node_id.startswith(incomplete)]\n\n    except Exception:\n        return []\n\n\ndef node_or_all_completer(incomplete: str) -> list[str]:\n    try:\n        config_manager = ConfigManager()\n        tmp_path = os.path.join(config_manager.get_config_path(), \"tmp\", \"node-cache.list\")\n\n        all_opt = []\n        if \"all\".startswith(incomplete):\n            all_opt = [\"all\"]\n\n        with open(tmp_path, encoding=\"UTF-8\", errors=\"ignore\") as cache_file:\n            return [node_id for node_id in cache_file.readlines() if node_id.startswith(incomplete)] + all_opt\n\n    except Exception:\n        return []\n\n\ndef validate_mode(mode):\n    valid_modes = [\"remote\", \"local\", \"cache\"]\n    if mode and mode.lower() not in valid_modes:\n        typer.echo(\n            f\"Invalid mode: {mode}. Allowed modes are 'remote', 'local', 'cache'.\",\n            err=True,\n        )\n        raise typer.Exit(code=1)\n\n\n@app.command(help=\"Show node list\")\n@tracking.track_command(\"node\")\ndef show(\n    arg: ShowTarget = typer.Argument(\n        help=\"Target to display\",\n    ),\n    channel: Annotated[\n        str | None,\n        typer.Option(\n            show_default=False,\n            help=\"Specify the operation mode\",\n            autocompletion=channel_completer,\n        ),\n    ] = None,\n    mode: str = typer.Option(\n        None,\n        help=\"[remote|local|cache]\",\n        autocompletion=mode_completer,\n    ),\n):\n    validate_mode(mode)\n\n    execute_cm_cli([\"show\", arg.value], channel=channel, mode=mode)\n\n\n@app.command(\"simple-show\", help=\"Show node list (simple mode)\")\n@tracking.track_command(\"node\")\ndef simple_show(\n    arg: ShowTarget = typer.Argument(\n        help=\"Target to display\",\n    ),\n    channel: Annotated[\n        str | None,\n        typer.Option(\n            show_default=False,\n            help=\"Specify the operation mode\",\n            autocompletion=channel_completer,\n        ),\n    ] = None,\n    mode: str = typer.Option(\n        None,\n        help=\"[remote|local|cache]\",\n        autocompletion=mode_completer,\n    ),\n):\n    validate_mode(mode)\n\n    execute_cm_cli([\"simple-show\", arg.value], channel=channel, mode=mode)\n\n\n# install, reinstall, uninstall\n@app.command(help=\"Install custom nodes\")\n@tracking.track_command(\"node\")\ndef install(\n    nodes: list[str] = typer.Argument(..., help=\"List of custom nodes to install\", autocompletion=node_completer),\n    channel: Annotated[\n        str | None,\n        typer.Option(\n            show_default=False,\n            help=\"Specify the operation mode\",\n            autocompletion=channel_completer,\n        ),\n    ] = None,\n    fast_deps: Annotated[\n        bool,\n        typer.Option(\n            \"--fast-deps\",\n            show_default=False,\n            help=\"Use new fast dependency installer\",\n        ),\n    ] = False,\n    no_deps: Annotated[\n        bool,\n        typer.Option(\n            \"--no-deps\",\n            show_default=False,\n            help=\"Skip dependency installation\",\n        ),\n    ] = False,\n    uv_compile: Annotated[\n        bool | None,\n        typer.Option(\n            \"--uv-compile/--no-uv-compile\",\n            show_default=False,\n            help=\"After installing, batch-resolve all dependencies via uv pip compile (requires ComfyUI-Manager v4.1+)\",\n        ),\n    ] = None,\n    exit_on_fail: Annotated[\n        bool,\n        typer.Option(\n            \"--exit-on-fail\",\n            help=\"Exit on failure\",\n        ),\n    ] = False,\n    mode: str = typer.Option(\n        None,\n        help=\"[remote|local|cache]\",\n        autocompletion=mode_completer,\n    ),\n):\n    if \"all\" in nodes:\n        typer.echo(\"`install all` is not allowed\", err=True)\n        raise typer.Exit(code=1)\n\n    exclusive_flags = [\n        name for name, val in [(\"--fast-deps\", fast_deps), (\"--no-deps\", no_deps), (\"--uv-compile\", uv_compile)] if val\n    ]\n    if len(exclusive_flags) > 1:\n        typer.echo(f\"Cannot use {' and '.join(exclusive_flags)} together\", err=True)\n        raise typer.Exit(code=1)\n\n    effective_uv_compile = _resolve_uv_compile(uv_compile, fast_deps=fast_deps, no_deps=no_deps)\n\n    validate_mode(mode)\n\n    if exit_on_fail:\n        cmd = [\"install\", \"--exit-on-fail\"] + nodes\n    else:\n        cmd = [\"install\"] + nodes\n\n    try:\n        execute_cm_cli(\n            cmd,\n            channel=channel,\n            fast_deps=fast_deps,\n            no_deps=no_deps,\n            uv_compile=effective_uv_compile,\n            mode=mode,\n            raise_on_error=exit_on_fail,\n        )\n    except subprocess.CalledProcessError as e:\n        if exit_on_fail:\n            raise typer.Exit(code=e.returncode)\n\n\n@app.command(help=\"Reinstall custom nodes\")\n@tracking.track_command(\"node\")\ndef reinstall(\n    nodes: list[str] = typer.Argument(..., help=\"List of custom nodes to reinstall\", autocompletion=node_completer),\n    channel: Annotated[\n        str | None,\n        typer.Option(\n            show_default=False,\n            help=\"Specify the operation mode\",\n            autocompletion=channel_completer,\n        ),\n    ] = None,\n    fast_deps: Annotated[\n        bool,\n        typer.Option(\n            \"--fast-deps\",\n            show_default=False,\n            help=\"Use new fast dependency installer\",\n        ),\n    ] = False,\n    uv_compile: Annotated[\n        bool | None,\n        typer.Option(\n            \"--uv-compile/--no-uv-compile\",\n            show_default=False,\n            help=\"After reinstalling, batch-resolve all dependencies via uv pip compile (requires ComfyUI-Manager v4.1+)\",\n        ),\n    ] = None,\n    mode: str = typer.Option(\n        None,\n        help=\"[remote|local|cache]\",\n        autocompletion=mode_completer,\n    ),\n):\n    if \"all\" in nodes:\n        typer.echo(\"`reinstall all` is not allowed\", err=True)\n        raise typer.Exit(code=1)\n\n    exclusive_flags = [name for name, val in [(\"--fast-deps\", fast_deps), (\"--uv-compile\", uv_compile)] if val]\n    if len(exclusive_flags) > 1:\n        typer.echo(f\"Cannot use {' and '.join(exclusive_flags)} together\", err=True)\n        raise typer.Exit(code=1)\n\n    effective_uv_compile = _resolve_uv_compile(uv_compile, fast_deps=fast_deps)\n\n    validate_mode(mode)\n\n    execute_cm_cli(\n        [\"reinstall\"] + nodes, channel=channel, fast_deps=fast_deps, uv_compile=effective_uv_compile, mode=mode\n    )\n\n\n@app.command(\n    \"uv-sync\",\n    help=\"Batch-resolve and install all custom node dependencies via uv (requires ComfyUI-Manager v4.1+)\",\n)\n@tracking.track_command(\"node\")\ndef uv_sync():\n    execute_cm_cli([\"uv-sync\"])\n\n\n@app.command(help=\"Uninstall custom nodes\")\n@tracking.track_command(\"node\")\ndef uninstall(\n    nodes: list[str] = typer.Argument(..., help=\"List of custom nodes to uninstall\", autocompletion=node_completer),\n    channel: Annotated[\n        str | None,\n        typer.Option(\n            show_default=False,\n            help=\"Specify the operation mode\",\n            autocompletion=channel_completer,\n        ),\n    ] = None,\n    mode: str = typer.Option(\n        None,\n        help=\"[remote|local|cache]\",\n        autocompletion=mode_completer,\n    ),\n):\n    if \"all\" in nodes:\n        typer.echo(\"`uninstall all` is not allowed\", err=True)\n        raise typer.Exit(code=1)\n\n    validate_mode(mode)\n\n    execute_cm_cli([\"uninstall\"] + nodes, channel=channel, mode=mode)\n\n\ndef update_node_id_cache():\n    config_manager = ConfigManager()\n    workspace_path = workspace_manager.workspace_path\n\n    if not find_cm_cli():\n        raise FileNotFoundError(\"cm-cli not found\")\n\n    tmp_path = os.path.join(config_manager.get_config_path(), \"tmp\")\n    if not os.path.exists(tmp_path):\n        os.makedirs(tmp_path)\n\n    cache_path = os.path.join(tmp_path, \"node-cache.list\")\n    python = resolve_workspace_python(workspace_path)\n    cmd = [python, \"-m\", \"cm_cli\", \"export-custom-node-ids\", cache_path]\n\n    new_env = os.environ.copy()\n    new_env[\"COMFYUI_PATH\"] = workspace_path\n    subprocess.run(cmd, env=new_env, check=True)\n\n\n# `update, disable, enable, fix` allows `all` param\n@app.command(help=\"Update custom nodes or ComfyUI\")\n@tracking.track_command(\"node\")\ndef update(\n    nodes: list[str] = typer.Argument(\n        ...,\n        help=\"[all|List of custom nodes to update]\",\n        autocompletion=node_or_all_completer,\n    ),\n    channel: Annotated[\n        str | None,\n        typer.Option(\n            show_default=False,\n            help=\"Specify the operation mode\",\n            autocompletion=channel_completer,\n        ),\n    ] = None,\n    uv_compile: Annotated[\n        bool | None,\n        typer.Option(\n            \"--uv-compile/--no-uv-compile\",\n            show_default=False,\n            help=\"After updating, batch-resolve all dependencies via uv pip compile (requires ComfyUI-Manager v4.1+)\",\n        ),\n    ] = None,\n    mode: str = typer.Option(\n        None,\n        help=\"[remote|local|cache]\",\n        autocompletion=mode_completer,\n    ),\n):\n    validate_mode(mode)\n\n    execute_cm_cli([\"update\"] + nodes, channel=channel, uv_compile=_resolve_uv_compile(uv_compile), mode=mode)\n\n    update_node_id_cache()\n\n\n@app.command(help=\"Disable custom nodes\")\n@tracking.track_command(\"node\")\ndef disable(\n    nodes: list[str] = typer.Argument(\n        ...,\n        help=\"[all|List of custom nodes to disable]\",\n        autocompletion=node_or_all_completer,\n    ),\n    channel: Annotated[\n        str | None,\n        typer.Option(\n            show_default=False,\n            help=\"Specify the operation mode\",\n            autocompletion=channel_completer,\n        ),\n    ] = None,\n    mode: str = typer.Option(\n        None,\n        help=\"[remote|local|cache]\",\n        autocompletion=mode_completer,\n    ),\n):\n    validate_mode(mode)\n\n    execute_cm_cli([\"disable\"] + nodes, channel=channel, mode=mode)\n\n\n@app.command(help=\"Enable custom nodes\")\n@tracking.track_command(\"node\")\ndef enable(\n    nodes: list[str] = typer.Argument(\n        ...,\n        help=\"[all|List of custom nodes to enable]\",\n        autocompletion=node_or_all_completer,\n    ),\n    channel: Annotated[\n        str | None,\n        typer.Option(\n            show_default=False,\n            help=\"Specify the operation mode\",\n            autocompletion=channel_completer,\n        ),\n    ] = None,\n    mode: str = typer.Option(\n        None,\n        help=\"[remote|local|cache]\",\n        autocompletion=mode_completer,\n    ),\n):\n    validate_mode(mode)\n\n    execute_cm_cli([\"enable\"] + nodes, channel=channel, mode=mode)\n\n\n@app.command(help=\"Fix dependencies of custom nodes\")\n@tracking.track_command(\"node\")\ndef fix(\n    nodes: list[str] = typer.Argument(\n        ...,\n        help=\"[all|List of custom nodes to fix]\",\n        autocompletion=node_or_all_completer,\n    ),\n    channel: Annotated[\n        str | None,\n        typer.Option(\n            show_default=False,\n            help=\"Specify the operation mode\",\n            autocompletion=channel_completer,\n        ),\n    ] = None,\n    uv_compile: Annotated[\n        bool | None,\n        typer.Option(\n            \"--uv-compile/--no-uv-compile\",\n            show_default=False,\n            help=\"After fixing, batch-resolve all dependencies via uv pip compile (requires ComfyUI-Manager v4.1+)\",\n        ),\n    ] = None,\n    mode: str = typer.Option(\n        None,\n        help=\"[remote|local|cache]\",\n        autocompletion=mode_completer,\n    ),\n):\n    validate_mode(mode)\n\n    execute_cm_cli([\"fix\"] + nodes, channel=channel, uv_compile=_resolve_uv_compile(uv_compile), mode=mode)\n\n\n@app.command(\n    \"install-deps\",\n    help=\"Install dependencies from dependencies file(.json) or workflow(.png/.json)\",\n)\n@tracking.track_command(\"node\")\ndef install_deps(\n    deps: Annotated[\n        str | None,\n        typer.Option(show_default=False, help=\"Dependency spec file (.json)\"),\n    ] = None,\n    workflow: Annotated[\n        str | None,\n        typer.Option(show_default=False, help=\"Workflow file (.json/.png)\"),\n    ] = None,\n    channel: Annotated[\n        str | None,\n        typer.Option(\n            show_default=False,\n            help=\"Specify the operation mode\",\n            autocompletion=channel_completer,\n        ),\n    ] = None,\n    uv_compile: Annotated[\n        bool | None,\n        typer.Option(\n            \"--uv-compile/--no-uv-compile\",\n            show_default=False,\n            help=\"After installing, batch-resolve all dependencies via uv pip compile (requires ComfyUI-Manager v4.1+)\",\n        ),\n    ] = None,\n    mode: str = typer.Option(\n        None,\n        help=\"[remote|local|cache]\",\n        autocompletion=mode_completer,\n    ),\n):\n    validate_mode(mode)\n\n    if deps is None and workflow is None:\n        print(\"[bold red]One of --deps or --workflow must be provided as an argument.[/bold red]\\n\")\n\n    effective_uv_compile = _resolve_uv_compile(uv_compile)\n\n    tmp_path = None\n    if workflow is not None:\n        workflow = os.path.abspath(os.path.expanduser(workflow))\n        tmp_path = os.path.join(workspace_manager.config_manager.get_config_path(), \"tmp\")\n        if not os.path.exists(tmp_path):\n            os.makedirs(tmp_path)\n        tmp_path = os.path.join(tmp_path, str(uuid.uuid4())) + \".json\"\n\n        execute_cm_cli(\n            [\"deps-in-workflow\", \"--workflow\", workflow, \"--output\", tmp_path],\n            channel,\n            mode=mode,\n        )\n\n        deps_file = tmp_path\n    else:\n        deps_file = os.path.abspath(os.path.expanduser(deps))\n\n    execute_cm_cli([\"install-deps\", deps_file], channel=channel, uv_compile=effective_uv_compile, mode=mode)\n\n    if tmp_path is not None and os.path.exists(tmp_path):\n        os.remove(tmp_path)\n\n\n@app.command(\"deps-in-workflow\", help=\"Generate dependencies file from workflow (.json/.png)\")\n@tracking.track_command(\"node\")\ndef deps_in_workflow(\n    workflow: Annotated[str, typer.Option(show_default=False, help=\"Workflow file (.json/.png)\")],\n    output: Annotated[str, typer.Option(show_default=False, help=\"Output file (.json)\")],\n    channel: Annotated[\n        str | None,\n        typer.Option(\n            show_default=False,\n            help=\"Specify the operation mode\",\n            autocompletion=channel_completer,\n        ),\n    ] = None,\n    mode: str = typer.Option(\n        None,\n        help=\"[remote|local|cache]\",\n        autocompletion=mode_completer,\n    ),\n):\n    validate_mode(mode)\n\n    workflow = os.path.abspath(os.path.expanduser(workflow))\n    output = os.path.abspath(os.path.expanduser(output))\n\n    execute_cm_cli(\n        [\"deps-in-workflow\", \"--workflow\", workflow, \"--output\", output],\n        channel,\n        mode=mode,\n    )\n\n\ndef validate_node_for_publishing():\n    \"\"\"\n    Validates node configuration and runs security checks.\n    Returns the validated config if successful, raises typer.Exit if validation fails.\n    \"\"\"\n    # Perform some validation logic here\n    typer.echo(\"Validating node configuration...\")\n    config = extract_node_configuration()\n    if config is None:\n        raise typer.Exit(code=1)\n\n    if not config.project.version:\n        # Escape `[` chars so rich doesn't parse `[tool.comfy.version]` and\n        # `[\"version\"]` as markup tags; `]` doesn't need escaping.\n        print(\n            \"[red]Error: project version is empty. Set `project.version` in pyproject.toml, \"\n            r'or configure `\\[tool.comfy.version].path` if using `dynamic = \\[\"version\"]`.[/red]'\n        )\n        raise typer.Exit(code=1)\n\n    # Run security checks first\n    typer.echo(\"Running security checks...\")\n    try:\n        # Run ruff check with security rules and --exit-zero to only warn\n        cmd = [sys.executable, \"-m\", \"ruff\", \"check\", \".\", \"-q\", \"--select\", \"S102,S307,E702\", \"--exit-zero\"]\n        result = subprocess.run(cmd, capture_output=True, text=True)\n\n        if result.stdout:\n            print(\"[yellow]Security warnings found:[/yellow]\")\n            print(result.stdout)\n            print(\n                \"[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]\"\n            )\n        else:\n            print(\"[green]✓ All validation checks passed successfully[/green]\")\n\n    except FileNotFoundError:\n        print(\"[red]Ruff is not installed. Please install it with 'pip install ruff'[/red]\")\n        raise typer.Exit(code=1)\n    except Exception as e:\n        print(f\"[red]Error running security check: {e}[/red]\")\n        raise typer.Exit(code=1)\n\n    return config\n\n\n@app.command(\"validate\", help=\"Run validation checks for publishing\")\n@tracking.track_command(\"publish\")\ndef validate():\n    \"\"\"\n    Run validation checks that would be performed during publishing.\n    \"\"\"\n    validate_node_for_publishing()\n    # print(\"[green]✓ All validation checks passed successfully[/green]\")\n\n\n@app.command(\"publish\", help=\"Publish node to registry\")\n@tracking.track_command(\"publish\")\ndef publish(\n    token: str | None = typer.Option(None, \"--token\", help=\"Personal Access Token for publishing\", hide_input=True),\n):\n    \"\"\"\n    Publish a node with optional validation.\n    \"\"\"\n    config = validate_node_for_publishing()\n\n    # Prompt for API Key\n    if not token:\n        token = typer.prompt(\n            \"Please enter your API Key (can be created on https://registry.comfy.org)\",\n            hide_input=True,\n        )\n\n    # Call API to fetch node version with the token in the body\n    typer.echo(\"Publishing node version...\")\n    try:\n        response = registry_api.publish_node_version(config, token)\n        # Zip up all files in the current directory, respecting .gitignore files.\n        signed_url = response.signedUrl\n        zip_filename = NODE_ZIP_FILENAME\n        typer.echo(\"Creating zip file...\")\n\n        includes = config.tool_comfy.includes if config and config.tool_comfy else []\n\n        if includes:\n            typer.echo(f\"Including additional directories: {', '.join(includes)}\")\n\n        zip_files(zip_filename, includes=includes)\n\n        # Upload the zip file to the signed URL\n        typer.echo(\"Uploading zip file...\")\n        upload_file_to_signed_url(signed_url, zip_filename)\n    except Exception as e:\n        ui.display_error_message({str(e)})\n        raise typer.Exit(code=1)\n\n\n@app.command(\"init\", help=\"Init scaffolding for custom node\")\n@tracking.track_command(\"node\")\ndef scaffold():\n    if os.path.exists(\"pyproject.toml\"):\n        typer.echo(\"Warning: 'pyproject.toml' already exists. Will not overwrite.\")\n        raise typer.Exit(code=1)\n\n    typer.echo(\"Initializing metadata...\")\n    initialize_project_config()\n    typer.echo(\"pyproject.toml created successfully. Defaults were filled in. Please check before publishing.\")\n\n\n@app.command(\"registry-list\", help=\"List all nodes in the registry\", hidden=True)\n@tracking.track_command(\"node\")\ndef display_all_nodes():\n    \"\"\"\n    Display all nodes in the registry.\n    \"\"\"\n\n    nodes = None\n    try:\n        nodes = registry_api.list_all_nodes()\n    except Exception as e:\n        logging.error(f\"Failed to fetch nodes from the registry: {str(e)}\")\n        ui.display_error_message(\"Failed to fetch nodes from the registry.\")\n\n    # Map Node data class instances to tuples for display\n    node_data = [\n        (\n            node.id,\n            node.name,\n            node.description,\n            node.author or \"N/A\",\n            node.license or \"N/A\",\n            \", \".join(node.tags),\n            node.latest_version.version if node.latest_version else \"N/A\",\n        )\n        for node in nodes\n    ]\n    ui.display_table(\n        node_data,\n        [\n            \"ID\",\n            \"Name\",\n            \"Description\",\n            \"Author\",\n            \"License\",\n            \"Tags\",\n            \"Latest Version\",\n        ],\n        title=\"List of All Nodes\",\n    )\n\n\n@app.command(\n    \"registry-install\",\n    help=\"Install a node from the registry\",\n    hidden=True,\n)\n@tracking.track_command(\"node\")\ndef registry_install(\n    node_id: str,\n    version: str | None = None,\n    force_download: Annotated[\n        bool,\n        typer.Option(\n            \"--force-download\",\n            help=\"Force download the node even if it is already installed\",\n        ),\n    ] = False,\n):\n    \"\"\"\n    Install a node from the registry.\n    Args:\n      node_id: The ID of the node to install.\n      version: The version of the node to install. If not provided, the latest version will be installed.\n    \"\"\"\n\n    # If the node ID is not provided, prompt the user to enter it\n    if not node_id:\n        node_id = typer.prompt(\"Enter the ID of the node you want to install\")\n\n    node_version = None\n    try:\n        # Call the API to install the node\n        node_version = registry_api.install_node(node_id, version)\n        if not node_version.download_url:\n            logging.error(\"Download URL not provided from the registry.\")\n            ui.display_error_message(f\"Failed to download the custom node {node_id}.\")\n            return\n\n    except Exception as e:\n        logging.error(f\"Encountered an error while installing the node. error: {str(e)}\")\n        ui.display_error_message(f\"Failed to download the custom node {node_id}.\")\n        return\n\n    # Download the node archive\n    custom_nodes_path = pathlib.Path(workspace_manager.workspace_path) / \"custom_nodes\"\n    node_specific_path = custom_nodes_path / node_id  # Subdirectory for the node\n    if node_specific_path.exists():\n        print(\n            f\"[bold red] The node {node_id} already exists in the workspace. This might delete any model files in the node.[/bold red]\"\n        )\n\n        confirm = ui.prompt_confirm_action(\n            \"Do you want to overwrite it?\",\n            force_download,\n        )\n        if not confirm:\n            return\n    node_specific_path.mkdir(parents=True, exist_ok=True)  # Create the directory if it doesn't exist\n\n    local_filename = node_specific_path / f\"{node_id}-{node_version.version}.zip\"\n    logging.debug(f\"Start downloading the node {node_id} version {node_version.version} to {local_filename}\")\n    try:\n        download_file(node_version.download_url, local_filename)\n    except DownloadException as e:\n        logging.error(f\"Failed to download node {node_id} version {node_version.version}: {e}\")\n        ui.display_error_message(f\"Failed to download the custom node {node_id}: {e}\")\n        raise typer.Exit(code=1) from None\n\n    # Extract the downloaded archive to the custom_node directory on the workspace.\n    logging.debug(f\"Start extracting the node {node_id} version {node_version.version} to {custom_nodes_path}\")\n    extract_package_as_zip(local_filename, node_specific_path)\n\n    # TODO: temoporary solution to run requirement.txt and install script\n    execute_install_script(node_specific_path)\n\n    # Delete the downloaded archive\n    logging.debug(f\"Deleting the downloaded archive {local_filename}\")\n    os.remove(local_filename)\n\n    logging.info(f\"Node {node_id} version {node_version.version} has been successfully installed.\")\n\n\n@app.command(\n    \"pack\",\n    help=\"Pack the current node into a zip file using git-tracked files and honoring .comfyignore patterns.\",\n)\n@tracking.track_command(\"pack\")\ndef pack():\n    typer.echo(\"Validating node configuration...\")\n    config = extract_node_configuration()\n    if not config:\n        raise typer.Exit(code=1)\n\n    zip_filename = NODE_ZIP_FILENAME\n    includes = config.tool_comfy.includes if config and config.tool_comfy else []\n\n    if includes:\n        typer.echo(f\"Including additional directories: {', '.join(includes)}\")\n\n    zip_files(zip_filename, includes=includes)\n\n    typer.echo(f\"Created zip file: {NODE_ZIP_FILENAME}\")\n    logging.info(\"Node has been packed successfully.\")\n\n\n@app.command(\"scaffold\", help=\"Create a new ComfyUI custom node project using cookiecutter\")\n@tracking.track_command(\"node\")\ndef scaffold_cookiecutter():\n    \"\"\"Create a new ComfyUI custom node project using cookiecutter.\"\"\"\n    import cookiecutter.main\n\n    try:\n        cookiecutter.main.cookiecutter(\n            \"gh:comfy-org/cookiecutter-comfy-extension\",\n            overwrite_if_exists=True,\n        )\n        console.print(\"[bold green]✓ Custom node project created successfully![/bold green]\")\n    except Exception as e:\n        console.print(f\"[bold red]Error creating project: {str(e)}[/bold red]\")\n        raise typer.Exit(code=1)\n"
  },
  {
    "path": "comfy_cli/command/generate/__init__.py",
    "content": "from comfy_cli.command.generate.app import register_with\n\n__all__ = [\"register_with\"]\n"
  },
  {
    "path": "comfy_cli/command/generate/adapters.py",
    "content": "\"\"\"Per-endpoint adapters for partners whose request/response shapes don't fit\nthe generic schema-driven flag→JSON mold.\n\nTwo endpoints today:\n\n- **Gemini Flash Image (nano-banana)** — Vertex AI's ``contents``/``parts``\n  body, inline base64 image input, and inline base64 image output. The model\n  variant lives in the URL path, not the body.\n- **Seedance** (ByteDance) — assembles a ``content`` array of typed parts\n  (``text`` + optional ``image_url``) and inlines its own knobs (resolution,\n  duration, …) into the prompt string.\n\nAn adapter contributes three optional pieces:\n\n- ``flags`` — replaces the schema-derived flag list for the model\n- ``build_body`` — produces the JSON body from parsed flag values\n- ``decode_sync`` — handles a sync response that ships inline blobs (Gemini)\n- ``path_param`` — name of a flag whose value gets substituted into the URL\n  path's ``{placeholder}`` (e.g. ``model`` for Gemini's templated path)\n\"\"\"\n\nfrom __future__ import annotations\n\nimport base64\nimport mimetypes\nfrom collections.abc import Callable\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Any\n\nimport httpx\n\nfrom comfy_cli.command.generate.client import ApiError\nfrom comfy_cli.command.generate.schema import FlagDef\n\n\n@dataclass(frozen=True)\nclass Adapter:\n    flags: list[FlagDef]\n    build_body: Callable[[dict, str], dict]\n    decode_sync: Callable[[dict, str, str], list[Path]] | None = None\n    path_param: str | None = None\n\n\n# ── Gemini / nano-banana ──────────────────────────────────────────────────\n\nGEMINI_IMAGE_MODELS = (\n    \"gemini-2.5-flash-image\",\n    \"gemini-2.5-flash-image-preview\",\n    \"gemini-3-pro-image-preview\",\n)\n\n\ndef _inline_image(value: str) -> tuple[str, str]:\n    \"\"\"Return ``(mime_type, base64_str)`` for a local path, http(s) URL, or\n    ``data:`` URI. Gemini accepts inline-only — there's no signed-URL path\n    here, so we pull bytes locally rather than going through ``upload.py``.\"\"\"\n    if value.startswith(\"data:\"):\n        head, _, b64 = value.partition(\",\")\n        mime = head.split(\";\", 1)[0].removeprefix(\"data:\") or \"image/png\"\n        return mime, b64\n    if value.startswith((\"http://\", \"https://\")):\n        with httpx.Client(timeout=60.0, follow_redirects=True) as c:\n            r = c.get(value)\n            r.raise_for_status()\n            mime = (r.headers.get(\"content-type\") or \"image/png\").split(\";\", 1)[0].strip()\n            return mime, base64.b64encode(r.content).decode(\"ascii\")\n    path = Path(value).expanduser()\n    if not path.is_file():\n        raise ApiError(0, \"\", f\"Image not found: {path}\")\n    mime, _ = mimetypes.guess_type(path.name)\n    return mime or \"image/png\", base64.b64encode(path.read_bytes()).decode(\"ascii\")\n\n\ndef _gemini_build_body(values: dict, api_key: str) -> dict[str, Any]:\n    parts: list[dict[str, Any]] = [{\"text\": str(values[\"prompt\"])}]\n    images = values.get(\"image\") or []\n    if isinstance(images, str):\n        images = [images]\n    for img in images:\n        mime, b64 = _inline_image(str(img))\n        parts.append({\"inlineData\": {\"mimeType\": mime, \"data\": b64}})\n    return {\n        \"contents\": [{\"role\": \"user\", \"parts\": parts}],\n        \"generationConfig\": {\"responseModalities\": [\"IMAGE\"]},\n    }\n\n\ndef _gemini_decode_sync(body: dict, download: str, request_id: str) -> list[Path]:\n    \"\"\"Walk candidates[*].content.parts[*].inlineData; save each blob.\"\"\"\n    from comfy_cli.command.generate import output\n\n    blobs: list[tuple[str, bytes]] = []\n    for cand in body.get(\"candidates\") or []:\n        content = cand.get(\"content\") or {}\n        for part in content.get(\"parts\") or []:\n            inline = part.get(\"inlineData\") or part.get(\"inline_data\")\n            if not inline:\n                continue\n            data_b64 = inline.get(\"data\") or \"\"\n            mime = inline.get(\"mimeType\") or inline.get(\"mime_type\") or \"image/png\"\n            try:\n                raw = base64.b64decode(data_b64, validate=False)\n            except (ValueError, TypeError):\n                continue\n            blobs.append((mime, raw))\n    if not blobs:\n        return []\n    return output.save_inline_blobs(blobs, download, request_id)\n\n\n_gemini_adapter = Adapter(\n    flags=[\n        FlagDef(\n            name=\"prompt\",\n            kind=\"string\",\n            required=True,\n            description=\"Text instruction. For edits, describe the change.\",\n        ),\n        FlagDef(\n            name=\"image\",\n            kind=\"array\",\n            item_kind=\"string\",\n            required=False,\n            description=\"Optional reference image(s): local path, http(s) URL, or data URI.\",\n        ),\n        FlagDef(\n            name=\"model\",\n            kind=\"enum\",\n            required=False,\n            default=\"gemini-2.5-flash-image\",\n            description=\"Gemini image-model variant.\",\n            enum=list(GEMINI_IMAGE_MODELS),\n        ),\n    ],\n    build_body=_gemini_build_body,\n    decode_sync=_gemini_decode_sync,\n    path_param=\"model\",\n)\n\n\n# ── Seedance ──────────────────────────────────────────────────────────────\n\nSEEDANCE_MODELS = (\n    \"seedance-1-0-pro-250528\",\n    \"seedance-1-0-pro-fast-251015\",\n    \"seedance-1-5-pro-251215\",\n    \"seedance-1-0-lite-t2v-250428\",\n    \"seedance-1-0-lite-i2v-250428\",\n)\n\n_SEEDANCE_INLINE_KEYS = (\"resolution\", \"ratio\", \"duration\", \"fps\", \"seed\", \"camerafixed\", \"watermark\")\n\n\ndef _seedance_text(values: dict) -> str:\n    \"\"\"Compose the ``text`` field, appending Seedance's inline ``--rs/--rt/…``\n    style overrides for any flags the user set.\"\"\"\n    prompt = str(values[\"prompt\"])\n    extras: list[str] = []\n    for key in _SEEDANCE_INLINE_KEYS:\n        v = values.get(key)\n        if v is None or v == \"\":\n            continue\n        if isinstance(v, bool):\n            v = \"true\" if v else \"false\"\n        extras.append(f\"--{key} {v}\")\n    return f\"{prompt} {' '.join(extras)}\".strip()\n\n\ndef _seedance_image_url(value: str, api_key: str) -> str:\n    \"\"\"Local paths get uploaded; data: and http(s) pass through verbatim.\"\"\"\n    if value.startswith((\"http://\", \"https://\", \"data:\")):\n        return value\n    from comfy_cli.command.generate import upload\n\n    return upload.upload_path(Path(value).expanduser(), api_key).url\n\n\ndef _seedance_build_body(values: dict, api_key: str) -> dict[str, Any]:\n    content: list[dict[str, Any]] = [{\"type\": \"text\", \"text\": _seedance_text(values)}]\n    image = values.get(\"image\")\n    if image:\n        content.append({\"type\": \"image_url\", \"image_url\": {\"url\": _seedance_image_url(str(image), api_key)}})\n    body: dict[str, Any] = {\n        \"model\": values.get(\"model\") or SEEDANCE_MODELS[0],\n        \"content\": content,\n    }\n    if \"generate_audio\" in values:\n        body[\"generate_audio\"] = bool(values[\"generate_audio\"])\n    if \"return_last_frame\" in values:\n        body[\"return_last_frame\"] = bool(values[\"return_last_frame\"])\n    return body\n\n\n_seedance_adapter = Adapter(\n    flags=[\n        FlagDef(name=\"prompt\", kind=\"string\", required=True, description=\"Text prompt for the video.\"),\n        FlagDef(\n            name=\"image\",\n            kind=\"string\",\n            required=False,\n            description=\"Optional first-frame image (URL, local path, or data URI). \"\n            \"Local paths are auto-uploaded via /customers/storage.\",\n        ),\n        FlagDef(\n            name=\"model\",\n            kind=\"enum\",\n            required=False,\n            default=\"seedance-1-0-pro-250528\",\n            description=\"Seedance model variant.\",\n            enum=list(SEEDANCE_MODELS),\n        ),\n        FlagDef(name=\"resolution\", kind=\"enum\", required=False, enum=[\"480p\", \"720p\", \"1080p\"]),\n        FlagDef(\n            name=\"ratio\",\n            kind=\"enum\",\n            required=False,\n            enum=[\"21:9\", \"16:9\", \"4:3\", \"1:1\", \"3:4\", \"9:16\", \"9:21\", \"adaptive\"],\n        ),\n        FlagDef(name=\"duration\", kind=\"integer\", required=False, description=\"Length in seconds (3–12).\"),\n        FlagDef(name=\"fps\", kind=\"integer\", required=False, description=\"Frames per second (default 24).\"),\n        FlagDef(name=\"seed\", kind=\"integer\", required=False, description=\"RNG seed (-1 to 2^32-1).\"),\n        FlagDef(name=\"camerafixed\", kind=\"boolean\", required=False, description=\"Lock camera position.\"),\n        FlagDef(name=\"watermark\", kind=\"boolean\", required=False, description=\"Include a watermark.\"),\n        FlagDef(\n            name=\"generate_audio\",\n            kind=\"boolean\",\n            required=False,\n            description=\"Synthesize matching audio (Seedance 1.5 pro only).\",\n        ),\n        FlagDef(\n            name=\"return_last_frame\",\n            kind=\"boolean\",\n            required=False,\n            description=\"Return the last-frame image alongside the video.\",\n        ),\n    ],\n    build_body=_seedance_build_body,\n    decode_sync=None,\n    path_param=None,\n)\n\n\n_ADAPTERS: dict[str, Adapter] = {\n    \"vertexai/gemini/{model}\": _gemini_adapter,\n    \"byteplus/api/v3/contents/generations/tasks\": _seedance_adapter,\n}\n\n\ndef get(endpoint_id: str) -> Adapter | None:\n    return _ADAPTERS.get(endpoint_id)\n\n\ndef resolve_path(template: str, values: dict, adapter: Adapter) -> str:\n    \"\"\"Substitute ``adapter.path_param`` into the URL template, falling back to\n    the flag's ``default`` when the user didn't pass it.\"\"\"\n    if not adapter.path_param:\n        return template\n    val = values.get(adapter.path_param)\n    if not val:\n        for f in adapter.flags:\n            if f.name == adapter.path_param:\n                val = f.default\n                break\n    if not val:\n        raise ApiError(0, \"\", f\"Missing --{adapter.path_param}: required to fill in the URL path.\")\n    return template.replace(\"{\" + adapter.path_param + \"}\", str(val))\n"
  },
  {
    "path": "comfy_cli/command/generate/app.py",
    "content": "\"\"\"``comfy generate`` — call ComfyUI partner nodes from the CLI.\n\nUX shape, modeled on fal-ai's genmedia but creative-user-first:\n\n    comfy generate <model> [--<param> value]... [--download P] [--async]\n    comfy generate list [--partner P] [--style S]\n    comfy generate schema <model>\n    comfy generate refresh\n    comfy generate resume <model> <job_id> [--download P]\n\nThe first positional is either a reserved action (``list``/``schema``/\n``refresh``/``resume``) or a model alias (``flux-pro``, ``ideogram-edit``, …).\nAnything not in the reserved set falls through to the generate path.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport uuid\nfrom pathlib import Path\nfrom typing import Annotated\n\nimport httpx\nimport typer\nfrom rich import print as rprint\nfrom rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn\n\nfrom comfy_cli import tracking, ui\nfrom comfy_cli.command.generate import adapters, client, output, poll, schema, spec, upload\n\n_HELP = \"Generate images via ComfyUI partner nodes (Flux, Ideogram, DALL·E, Recraft, Stability, …).\"\n\n_CONTEXT_SETTINGS = {\n    \"allow_extra_args\": True,\n    \"ignore_unknown_options\": True,\n    \"help_option_names\": [],\n}\n\n\ndef register_with(parent: typer.Typer) -> None:\n    \"\"\"Wire the ``generate`` command into a Typer app. We register directly\n    (rather than as a sub-app via ``add_typer``) so the first positional after\n    ``generate`` can be a model alias — Click groups would treat that as a\n    subcommand name and error.\"\"\"\n\n    @parent.command(name=\"generate\", help=_HELP, context_settings=_CONTEXT_SETTINGS)\n    @tracking.track_command()\n    def _generate_entry(\n        ctx: typer.Context,\n        target: Annotated[\n            str | None,\n            typer.Argument(\n                help=\"A model alias (e.g. flux-pro, ideogram-edit, dalle) \"\n                \"or one of: list, schema, refresh, upload, resume.\",\n            ),\n        ] = None,\n    ) -> None:\n        if target is None or target in {\"-h\", \"--help\"}:\n            _print_top_help()\n            raise typer.Exit(code=0)\n        if target == \"list\":\n            return _list_models(list(ctx.args))\n        if target == \"schema\":\n            return _schema(list(ctx.args))\n        if target == \"refresh\":\n            return _refresh()\n        if target == \"upload\":\n            return _upload(list(ctx.args))\n        if target == \"resume\":\n            return _resume(list(ctx.args))\n        _generate(target, list(ctx.args))\n\n\ndef _separate_meta_flags(extra_args: list[str]) -> tuple[list[str], dict[str, str | bool]]:\n    \"\"\"Pull run-level flags out of the user's argv tail.\"\"\"\n    meta_names = {\"download\", \"async\", \"json\", \"timeout\", \"api-key\"}\n    meta: dict[str, str | bool] = {}\n    remaining: list[str] = []\n    i = 0\n    while i < len(extra_args):\n        tok = extra_args[i]\n        if tok.startswith(\"--\"):\n            body = tok[2:]\n            raw: str | None = None\n            if \"=\" in body:\n                body, raw = body.split(\"=\", 1)\n            if body in meta_names:\n                if body in {\"async\", \"json\"}:\n                    meta[body] = True if raw is None else raw.lower() not in {\"false\", \"0\", \"no\"}\n                    i += 1\n                    continue\n                if raw is None:\n                    if i + 1 >= len(extra_args):\n                        raise schema.SchemaError(f\"--{body}: missing value\")\n                    raw = extra_args[i + 1]\n                    i += 2\n                else:\n                    i += 1\n                meta[body] = raw\n                continue\n        remaining.append(tok)\n        i += 1\n    return remaining, meta\n\n\ndef _show_schema_help(endpoint: spec.Endpoint) -> None:\n    \"\"\"Print the schema-driven help block for a model.\"\"\"\n    flags = schema.flags_for(endpoint)\n    alias = spec.preferred_alias(endpoint.id)\n    name = alias or endpoint.id\n    if alias:\n        rprint(f\"[bold]Model:[/bold] {alias}  [dim]({endpoint.id})[/dim]\")\n    else:\n        rprint(f\"[bold]Model:[/bold] {endpoint.id}\")\n    body = schema.help_text(endpoint, flags)\n    rprint(body)\n    rprint(\"\")\n    rprint(\"[dim]Example:[/dim]\")\n    rprint(f\"  {schema.example_invocation(endpoint, flags, display_name=name)}\")\n\n\ndef _spinner() -> Progress:\n    return Progress(\n        SpinnerColumn(),\n        TextColumn(\"[bold blue]{task.description}\"),\n        TimeElapsedColumn(),\n        transient=True,\n    )\n\n\ndef _emit_result(result: poll.PollResult, *, request_id: str, download: str | None, as_json: bool) -> None:\n    if as_json:\n        output.print_json(result.raw)\n        return\n    if result.status != \"succeeded\":\n        rprint(f\"[bold red]Job {result.status}: {result.error or 'unknown error'}[/bold red]\")\n        output.print_json(result.raw)\n        raise typer.Exit(code=1)\n    if download and result.image_urls:\n        saved = output.save_urls(result.image_urls, download, request_id)\n        output.print_urls(result.image_urls, request_id=request_id)\n        output.print_saved(saved)\n    else:\n        output.print_urls(result.image_urls, request_id=request_id)\n        if download and not result.image_urls:\n            rprint(\"[yellow]--download requested but no image URLs found in response.[/yellow]\")\n\n\ndef _generate(model: str, extra_args: list[str]) -> None:\n    try:\n        ep = spec.get_endpoint(model)\n    except spec.SpecError as e:\n        rprint(f\"[bold red]{e}[/bold red]\")\n        raise typer.Exit(code=1)\n\n    if any(a in {\"--help\", \"-h\"} for a in extra_args):\n        _show_schema_help(ep)\n        raise typer.Exit(code=0)\n\n    try:\n        remaining, meta = _separate_meta_flags(extra_args)\n    except schema.SchemaError as e:\n        rprint(f\"[bold red]{e}[/bold red]\")\n        raise typer.Exit(code=1)\n\n    flags = schema.flags_for(ep)\n    try:\n        values = schema.parse_args(flags, remaining)\n    except schema.SchemaError as e:\n        rprint(f\"[bold red]{e}[/bold red]\")\n        name = spec.preferred_alias(ep.id) or ep.id\n        rprint(f\"[dim]Run `comfy generate schema {name}` for the full parameter list.[/dim]\")\n        raise typer.Exit(code=1)\n\n    try:\n        api_key = client.resolve_api_key(meta.get(\"api-key\") if isinstance(meta.get(\"api-key\"), str) else None)\n    except client.ApiError as e:\n        rprint(f\"[bold red]{e}[/bold red]\")\n        raise typer.Exit(code=1)\n\n    timeout_raw = meta.get(\"timeout\", \"300\")\n    try:\n        timeout = float(timeout_raw) if isinstance(timeout_raw, str) else 300.0\n    except ValueError:\n        rprint(f\"[bold red]--timeout: expected number, got {timeout_raw!r}[/bold red]\")\n        raise typer.Exit(code=1)\n\n    do_async = bool(meta.get(\"async\", False))\n    download = meta.get(\"download\") if isinstance(meta.get(\"download\"), str) else None\n    as_json = bool(meta.get(\"json\", False))\n\n    try:\n        _apply_upload_transforms(values, flags, ep, api_key)\n    except (client.ApiError, httpx.HTTPError) as e:\n        rprint(f\"[bold red]Upload failed: {e}[/bold red]\")\n        raise typer.Exit(code=1)\n\n    request_id = str(uuid.uuid4())[:8]\n    try:\n        resp = client.send_request(ep, values, flags, api_key, timeout=timeout)\n    except httpx.HTTPError as e:\n        rprint(f\"[bold red]Network error contacting {spec.base_url()}: {e}[/bold red]\")\n        raise typer.Exit(code=1) from e\n\n    try:\n        client.raise_for_status(resp)\n    except client.ApiError as e:\n        rprint(f\"[bold red]API error {e.status}[/bold red]\\n{e.body}\")\n        raise typer.Exit(code=1) from e\n\n    if resp.headers.get(\"content-type\", \"\").startswith(\"image/\"):\n        if download:\n            saved = output.save_binary_response(resp, download, request_id)\n            output.print_saved([saved])\n        else:\n            rprint(\"[yellow]Binary image response; nothing saved. Pass --download <path> to write it to disk.[/yellow]\")\n        return\n\n    try:\n        body = resp.json()\n    except ValueError:\n        rprint(\"[bold red]Unexpected non-JSON response.[/bold red]\")\n        rprint(resp.text[:500])\n        raise typer.Exit(code=1)\n\n    if ep.polling:\n        job_id = poll.extract_job_id(ep.polling, body) or request_id\n        name = spec.preferred_alias(ep.id) or ep.id\n        if do_async:\n            if as_json:\n                output.print_json(body)\n            else:\n                rprint(f\"[bold green]Submitted:[/bold green] {name}\")\n                rprint(f\"  job id: {job_id}\")\n                rprint(f\"  resume: comfy generate resume {name} {job_id}\")\n            return\n\n        poller = poll.get_poller(ep.polling)\n        with _spinner() as prog:\n            task = prog.add_task(f\"Generating with {name} (job {job_id})\", total=None)\n\n            def _on_progress(p: float) -> None:\n                prog.update(task, description=f\"Generating ({p * 100:.0f}%)\")\n\n            result = poller(\n                body,\n                api_key=api_key,\n                timeout=timeout,\n                on_progress=_on_progress,\n                create_path=ep.path,\n            )\n        _emit_result(result, request_id=job_id, download=download, as_json=as_json)\n        return\n\n    adapter = adapters.get(ep.id)\n    if adapter is not None and adapter.decode_sync is not None:\n        body = resp.json()\n        if as_json:\n            output.print_json(body)\n            return\n        if not download:\n            rprint(\"[yellow]Image data returned inline. Pass --download <path> to save.[/yellow]\")\n            return\n        saved = adapter.decode_sync(body, download, request_id)\n        if saved:\n            output.print_saved(saved)\n        else:\n            rprint(\"[yellow]No image data found in response.[/yellow]\")\n            output.print_json(body)\n        return\n\n    result = poll.sync_result_from_response(resp)\n    _emit_result(result, request_id=request_id, download=download, as_json=as_json)\n\n\ndef _arg_value(args: list[str], *names: str) -> str | None:\n    for i, tok in enumerate(args):\n        for n in names:\n            if tok == n and i + 1 < len(args):\n                return args[i + 1]\n            if tok.startswith(n + \"=\"):\n                return tok.split(\"=\", 1)[1]\n    return None\n\n\ndef _list_models(extra_args: list[str]) -> None:\n    \"\"\"`comfy generate list` — show available models with their short aliases.\"\"\"\n    partner = _arg_value(extra_args, \"--partner\", \"-p\")\n    category = _arg_value(extra_args, \"--category\", \"--style\", \"-c\")\n    query = _arg_value(extra_args, \"--query\", \"-q\")\n    eps = spec.list_endpoints(partner=partner, category=category, query=query)\n    if not eps:\n        rprint(\"[yellow]No models match those filters.[/yellow]\")\n        raise typer.Exit(code=0)\n    rows = [\n        (\n            spec.preferred_alias(e.id) or e.id,\n            e.partner,\n            e.category,\n            \"async\" if e.polling else \"sync\",\n            (e.summary[:60] + \"…\") if len(e.summary) > 61 else e.summary,\n        )\n        for e in eps\n    ]\n    ui.display_table(rows, [\"Model\", \"Partner\", \"Style\", \"Mode\", \"Summary\"], title=\"Comfy Generate — Models\")\n    rprint(\"\\n[dim]Run `comfy generate schema <model>` to see parameters for a model.[/dim]\")\n\n\ndef _schema(extra_args: list[str]) -> None:\n    \"\"\"`comfy generate schema <model>` — show params for a model (fal-style).\"\"\"\n    if not extra_args or extra_args[0].startswith(\"-\"):\n        rprint(\"[bold red]Usage: comfy generate schema <model>[/bold red]\")\n        raise typer.Exit(code=1)\n    try:\n        ep = spec.get_endpoint(extra_args[0])\n    except spec.SpecError as e:\n        rprint(f\"[bold red]{e}[/bold red]\")\n        raise typer.Exit(code=1)\n    _show_schema_help(ep)\n\n\ndef _refresh() -> None:\n    url = spec.base_url() + \"/openapi.yml\"\n    try:\n        with httpx.Client(timeout=30.0, follow_redirects=True) as cli:\n            r = cli.get(url, headers={\"Comfy-Env\": \"comfy-cli\", \"User-Agent\": \"comfy-cli/api\"})\n            r.raise_for_status()\n    except httpx.HTTPError as e:\n        rprint(f\"[bold red]Failed to fetch {url}: {e}[/bold red]\")\n        raise typer.Exit(code=1)\n    path = spec.write_cache(r.text)\n    rprint(f\"[bold green]Refreshed model catalog at {path}[/bold green]\")\n\n\ndef _upload(extra_args: list[str]) -> None:\n    \"\"\"`comfy generate upload <file-or-url> [--json] [--api-key K]`.\"\"\"\n    try:\n        remaining, meta = _separate_meta_flags(extra_args)\n    except schema.SchemaError as e:\n        rprint(f\"[bold red]{e}[/bold red]\")\n        raise typer.Exit(code=1)\n    # `remaining` already excludes recognized --meta flags AND their values, so\n    # `comfy generate upload --api-key KEY ./img.png` correctly resolves to \"./img.png\".\n    if not remaining:\n        rprint(\"[bold red]Usage: comfy generate upload <file-or-url> [--json][/bold red]\")\n        raise typer.Exit(code=1)\n    target = remaining[0]\n    try:\n        api_key = client.resolve_api_key(meta.get(\"api-key\") if isinstance(meta.get(\"api-key\"), str) else None)\n    except client.ApiError as e:\n        rprint(f\"[bold red]{e}[/bold red]\")\n        raise typer.Exit(code=1)\n    as_json = bool(meta.get(\"json\", False))\n    try:\n        result = upload.upload_target(target, api_key)\n    except (client.ApiError, httpx.HTTPError) as e:\n        rprint(f\"[bold red]Upload failed: {e}[/bold red]\")\n        raise typer.Exit(code=1)\n    if as_json:\n        output.print_json(\n            {\n                \"url\": result.url,\n                \"expires_at\": result.expires_at,\n                \"existing_file\": result.existing_file,\n                \"hint\": \"Pass this URL as the model's image/input_image field.\",\n            }\n        )\n        return\n    rprint(f\"[bold green]Uploaded:[/bold green] {result.url}\")\n    if result.expires_at:\n        rprint(f\"  expires: {result.expires_at}\")\n    if result.existing_file:\n        rprint(\"  [dim](server already had a hash-match; no bytes transferred)[/dim]\")\n\n\ndef _apply_upload_transforms(values: dict, flags: list[schema.FlagDef], endpoint: spec.Endpoint, api_key: str) -> None:\n    \"\"\"When the user supplies a local file path for a field that expects a\n    base64 blob or a URL, transform it transparently.\n\n    This only applies to JSON endpoints — multipart endpoints already stream\n    file paths natively via httpx and don't need pre-uploading. Endpoints with\n    a custom adapter handle their own asset shaping inside ``build_body``.\n    \"\"\"\n    if adapters.get(endpoint.id) is not None:\n        return\n    if endpoint.request_content_type != \"application/json\":\n        return\n    flag_by_name = {f.name: f for f in flags}\n    for name, value in list(values.items()):\n        flag = flag_by_name.get(name)\n        if flag is None or flag.upload_mode is None or not isinstance(value, str):\n            continue\n        if value.startswith((\"http://\", \"https://\", \"data:\")):\n            continue\n        path = Path(value).expanduser()\n        if not path.is_file():\n            continue\n        if flag.upload_mode == \"base64\":\n            import base64 as _base64\n\n            try:\n                data = path.read_bytes()\n            except OSError as e:\n                raise client.ApiError(0, \"\", f\"Unable to read file for --{name}: {path} ({e})\") from e\n            values[name] = _base64.b64encode(data).decode(\"ascii\")\n            rprint(f\"[dim]base64-encoded {path.name} for --{name}[/dim]\")\n        elif flag.upload_mode == \"url\":\n            rprint(f\"[dim]uploading {path.name} for --{name}…[/dim]\")\n            result = upload.upload_path(path, api_key)\n            values[name] = result.url\n\n\ndef _resume(extra_args: list[str]) -> None:\n    if len(extra_args) < 2:\n        rprint(\"[bold red]Usage: comfy generate resume <model> <job_id> [--download PATH] [--json][/bold red]\")\n        raise typer.Exit(code=1)\n    model, job_id = extra_args[0], extra_args[1]\n    tail = extra_args[2:]\n    try:\n        ep = spec.get_endpoint(model)\n    except spec.SpecError as e:\n        rprint(f\"[bold red]{e}[/bold red]\")\n        raise typer.Exit(code=1)\n    if not ep.polling:\n        rprint(f\"[bold red]{model} is a sync model; nothing to resume.[/bold red]\")\n        raise typer.Exit(code=1)\n    try:\n        _, meta = _separate_meta_flags(tail)\n    except schema.SchemaError as e:\n        rprint(f\"[bold red]{e}[/bold red]\")\n        raise typer.Exit(code=1)\n    try:\n        api_key = client.resolve_api_key(meta.get(\"api-key\") if isinstance(meta.get(\"api-key\"), str) else None)\n    except client.ApiError as e:\n        rprint(f\"[bold red]{e}[/bold red]\")\n        raise typer.Exit(code=1)\n    timeout = float(meta.get(\"timeout\") or 300.0) if isinstance(meta.get(\"timeout\"), str) else 300.0\n    download = meta.get(\"download\") if isinstance(meta.get(\"download\"), str) else None\n    as_json = bool(meta.get(\"json\", False))\n\n    try:\n        initial = poll.build_synthetic_initial(ep.polling, job_id, base_url=spec.base_url())\n    except client.ApiError as e:\n        rprint(f\"[bold red]{e}[/bold red]\")\n        raise typer.Exit(code=1)\n\n    poller = poll.get_poller(ep.polling)\n    with _spinner() as prog:\n        task = prog.add_task(f\"Resuming job {job_id}\", total=None)\n\n        def _on_progress(p: float) -> None:\n            prog.update(task, description=f\"Job {job_id} ({p * 100:.0f}%)\")\n\n        result = poller(\n            initial,\n            api_key=api_key,\n            timeout=timeout,\n            on_progress=_on_progress,\n            create_path=ep.path,\n        )\n    _emit_result(result, request_id=job_id, download=download, as_json=as_json)\n\n\ndef _print_top_help() -> None:\n    \"\"\"Custom help that emphasizes the model-first UX over Typer's auto-help.\"\"\"\n    rprint(\"[bold]comfy generate[/bold] — call ComfyUI partner nodes\")\n    rprint(\"\")\n    rprint(\"[bold]Usage:[/bold]\")\n    rprint(\"  comfy generate <model> [--<param> value]... [--download PATH] [--async] [--api-key KEY]\")\n    rprint(\"\")\n    rprint(\"[bold]Examples:[/bold]\")\n    rprint('  comfy generate flux-pro --prompt \"a cat on the moon\" --width 1024 --height 1024 --download cat.png')\n    rprint(\n        '  comfy generate ideogram-edit --image cat.png --mask m.png --prompt \"add sunglasses\" --rendering_speed TURBO'\n    )\n    rprint('  comfy generate dalle --prompt \"a watercolor whale\" --download whale.png')\n    rprint(\"\")\n    rprint(\"[bold]Actions:[/bold]\")\n    rprint(\"  comfy generate list                    Browse available models\")\n    rprint(\"  comfy generate schema <model>          Show parameters for a model\")\n    rprint(\"  comfy generate refresh                 Refresh the model catalog\")\n    rprint(\"  comfy generate upload <file-or-url>    Host a local file or remote URL and print its signed URL\")\n    rprint(\"  comfy generate resume <model> <job>    Resume an async job\")\n    rprint(\"\")\n    rprint(\"[dim]Auth: set COMFY_API_KEY or pass --api-key. Get one at https://platform.comfy.org.[/dim]\")\n"
  },
  {
    "path": "comfy_cli/command/generate/client.py",
    "content": "\"\"\"HTTP client for the Comfy cloud API.\n\nA thin wrapper around httpx that:\n- attaches ``Authorization: Bearer $COMFY_API_KEY`` to every request,\n- targets ``$COMFY_API_BASE_URL`` (defaulting to ``https://api.comfy.org``),\n- splits a request payload into JSON or multipart based on the endpoint's\n  declared content-type, streaming any ``format: binary`` fields as files.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom pathlib import Path\nfrom typing import Any\n\nimport httpx\n\nfrom comfy_cli.command.generate import spec\nfrom comfy_cli.command.generate.schema import FlagDef\n\n\nclass ApiError(RuntimeError):\n    def __init__(self, status: int, body: str, message: str | None = None) -> None:\n        super().__init__(message or f\"HTTP {status}: {body}\")\n        self.status = status\n        self.body = body\n\n\ndef resolve_api_key(explicit: str | None = None) -> str:\n    \"\"\"Order: explicit flag → COMFY_API_KEY env var. Raise if neither set.\"\"\"\n    key = explicit.strip() if isinstance(explicit, str) and explicit.strip() else os.environ.get(\"COMFY_API_KEY\", \"\")\n    key = key.strip()\n    if not key:\n        raise ApiError(\n            401,\n            \"\",\n            \"No API key. Pass --api-key or set COMFY_API_KEY in your environment. \"\n            \"Generate one at https://platform.comfy.org/api-keys.\",\n        )\n    return key\n\n\ndef _split_payload(\n    values: dict[str, Any], flags: list[FlagDef], content_type: str\n) -> tuple[dict[str, Any] | None, list[tuple[str, Any]] | None, dict[str, Any] | None]:\n    \"\"\"Return (json_body, multipart_files, multipart_data).\n\n    For JSON endpoints: json_body is the dict, others are None.\n    For multipart: files is a list of (field_name, (filename, fileobj, mime)) tuples\n    and data is the non-file form fields (stringified or JSON-encoded as needed).\n    \"\"\"\n    flag_by_name = {f.name: f for f in flags}\n    if content_type != \"multipart/form-data\":\n        return values, None, None\n\n    files: list[tuple[str, Any]] = []\n    data: dict[str, Any] = {}\n    for name, value in values.items():\n        flag = flag_by_name.get(name)\n        if flag and flag.kind == \"binary\":\n            path = Path(value) if not isinstance(value, Path) else value\n            if not path.is_file():\n                raise ApiError(0, \"\", f\"--{name}: file not found: {path}\")\n            files.append((name, (path.name, path.open(\"rb\"), \"application/octet-stream\")))\n        elif flag and flag.kind == \"array\" and flag.item_kind == \"binary\":\n            for p in value:\n                p = Path(p) if not isinstance(p, Path) else p\n                if not p.is_file():\n                    raise ApiError(0, \"\", f\"--{name}: file not found: {p}\")\n                files.append((name, (p.name, p.open(\"rb\"), \"application/octet-stream\")))\n        elif flag and flag.kind in (\"object\", \"array\"):\n            # Multipart form fields are scalar — JSON-encode complex values.\n            import json as _json\n\n            data[name] = _json.dumps(value)\n        elif flag and flag.kind == \"boolean\":\n            data[name] = \"true\" if value else \"false\"\n        else:\n            data[name] = str(value)\n    return None, files, data\n\n\ndef _auth_headers(api_key: str, extra: dict[str, str] | None = None) -> dict[str, str]:\n    # The server accepts two key types on different headers:\n    #   - \"comfyui-...\" API keys → X-API-Key (validated by sha256 lookup)\n    #   - Firebase ID tokens     → Authorization: Bearer (validated as a JWT)\n    # See comfy-api server/middleware/authentication/comfy_firebase_auth.go.\n    headers = {\"User-Agent\": \"comfy-cli/api\", \"Comfy-Env\": \"comfy-cli\"}\n    if api_key.startswith(\"comfyui-\"):\n        headers[\"X-API-Key\"] = api_key\n    else:\n        headers[\"Authorization\"] = f\"Bearer {api_key}\"\n    if extra:\n        headers.update(extra)\n    return headers\n\n\ndef send_request(\n    endpoint: spec.Endpoint,\n    values: dict[str, Any],\n    flags: list[FlagDef],\n    api_key: str,\n    timeout: float = 120.0,\n) -> httpx.Response:\n    \"\"\"Send the initial request for `endpoint` with the given typed values.\"\"\"\n    from comfy_cli.command.generate import adapters as _adapters\n\n    adapter = _adapters.get(endpoint.id)\n    url_path = _adapters.resolve_path(endpoint.path, values, adapter) if adapter else endpoint.path\n    url = spec.base_url() + url_path\n    if adapter is not None:\n        json_body, files, data = adapter.build_body(values, api_key), None, None\n    else:\n        json_body, files, data = _split_payload(values, flags, endpoint.request_content_type)\n    headers = _auth_headers(api_key)\n    try:\n        if endpoint.method.lower() == \"get\":\n            return httpx.get(url, params=values, headers=headers, timeout=timeout)\n        if endpoint.request_content_type == \"application/json\":\n            return httpx.post(url, json=json_body, headers=headers, timeout=timeout)\n        return httpx.post(url, files=files, data=data, headers=headers, timeout=timeout)\n    finally:\n        # Ensure file handles from multipart are closed even on httpx errors.\n        if files:\n            for _name, payload in files:\n                fileobj = payload[1]\n                try:\n                    fileobj.close()\n                except Exception:  # noqa: BLE001\n                    pass\n\n\ndef get(url: str, api_key: str, timeout: float = 60.0) -> httpx.Response:\n    \"\"\"GET helper for polling sibling endpoints and downloading result URLs.\"\"\"\n    if url.startswith(\"/\"):\n        url = spec.base_url() + url\n    return httpx.get(url, headers=_auth_headers(api_key), timeout=timeout)\n\n\ndef download_bytes(url: str, timeout: float = 120.0) -> bytes:\n    \"\"\"Fetch result media. These URLs are usually pre-signed and not Comfy-hosted,\n    so we don't send the Comfy bearer token.\"\"\"\n    with httpx.Client(timeout=timeout, follow_redirects=True) as client:\n        r = client.get(url)\n        r.raise_for_status()\n        return r.content\n\n\ndef raise_for_status(resp: httpx.Response) -> None:\n    if resp.status_code < 400:\n        return\n    try:\n        body = resp.json()\n        import json as _json\n\n        body_str = _json.dumps(body, indent=2)\n    except Exception:  # noqa: BLE001\n        body_str = resp.text\n    raise ApiError(resp.status_code, body_str)\n"
  },
  {
    "path": "comfy_cli/command/generate/output.py",
    "content": "\"\"\"Output handling: --download templating, URL printing, binary response writes.\n\nTemplating tokens: ``{request_id}``, ``{index}``, ``{ext}``. A trailing ``/``\non the template means \"use a default filename in this directory.\"\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport mimetypes\nfrom pathlib import Path\n\nimport httpx\nfrom rich import print as rprint\n\nfrom comfy_cli.command.generate import client\n\n_EXT_FROM_MIME = {\n    \"image/png\": \"png\",\n    \"image/jpeg\": \"jpg\",\n    \"image/jpg\": \"jpg\",\n    \"image/webp\": \"webp\",\n    \"image/gif\": \"gif\",\n    \"image/svg+xml\": \"svg\",\n}\n\n\ndef _ext_from_url(url: str) -> str:\n    suffix = Path(url.split(\"?\", 1)[0]).suffix.lstrip(\".\").lower()\n    return suffix or \"png\"\n\n\ndef _ext_from_response(resp: httpx.Response) -> str:\n    ct = resp.headers.get(\"content-type\", \"\").split(\";\", 1)[0].strip().lower()\n    if ct in _EXT_FROM_MIME:\n        return _EXT_FROM_MIME[ct]\n    guess = mimetypes.guess_extension(ct) or \"\"\n    return guess.lstrip(\".\") or \"bin\"\n\n\ndef _resolve_template(template: str, request_id: str, index: int, ext: str) -> Path:\n    if template.endswith((\"/\", \"\\\\\")) or Path(template).is_dir():\n        # Directory shorthand.\n        path = Path(template) / f\"{request_id}_{index}.{ext}\"\n    else:\n        path = Path(template.format(request_id=request_id, index=index, ext=ext))\n    return path.expanduser()\n\n\ndef save_urls(urls: list[str], template: str, request_id: str) -> list[Path]:\n    \"\"\"Download each URL and save under the resolved template path. Returns saved paths.\n\n    Multi-URL responses (a video + thumbnail from Luma, for example) need a\n    per-output filename. If the template has no ``{index}`` placeholder and\n    isn't a directory shorthand, we auto-insert ``_<i>`` before the suffix\n    and switch the extension to whatever the URL actually points at — so a\n    user who typed ``--download out.mp4`` doesn't silently get a thumbnail\n    JPEG written into ``out.mp4`` because the model returned two URLs.\"\"\"\n    saved: list[Path] = []\n    auto_index = len(urls) > 1 and \"{index}\" not in template and not template.endswith((\"/\", \"\\\\\"))\n    for i, url in enumerate(urls):\n        ext = _ext_from_url(url)\n        dest = _resolve_template(template, request_id, i, ext)\n        if auto_index:\n            dest = dest.with_name(f\"{dest.stem}_{i}.{ext}\")\n        dest.parent.mkdir(parents=True, exist_ok=True)\n        data = client.download_bytes(url)\n        dest.write_bytes(data)\n        saved.append(dest)\n    return saved\n\n\ndef save_inline_blobs(blobs: list[tuple[str, bytes]], template: str, request_id: str) -> list[Path]:\n    \"\"\"Save ``(mime, bytes)`` pairs returned inline (e.g. Gemini's\n    ``inlineData``) under the resolved template path. Same auto-indexing rule\n    as ``save_urls``: if the template has no ``{index}`` placeholder and isn't\n    a directory shorthand, multi-blob responses get ``_<i>`` inserted before\n    the suffix so the first blob doesn't get silently overwritten.\"\"\"\n    saved: list[Path] = []\n    auto_index = len(blobs) > 1 and \"{index}\" not in template and not template.endswith((\"/\", \"\\\\\"))\n    for i, (mime, data) in enumerate(blobs):\n        ext = _EXT_FROM_MIME.get(mime) or (mimetypes.guess_extension(mime) or \".png\").lstrip(\".\") or \"png\"\n        dest = _resolve_template(template, request_id, i, ext)\n        if auto_index:\n            dest = dest.with_name(f\"{dest.stem}_{i}.{ext}\")\n        dest.parent.mkdir(parents=True, exist_ok=True)\n        dest.write_bytes(data)\n        saved.append(dest)\n    return saved\n\n\ndef save_binary_response(resp: httpx.Response, template: str, request_id: str) -> Path:\n    \"\"\"Save a single binary response body (e.g. Stability returns image/* bytes).\"\"\"\n    ext = _ext_from_response(resp)\n    dest = _resolve_template(template, request_id, 0, ext)\n    dest.parent.mkdir(parents=True, exist_ok=True)\n    dest.write_bytes(resp.content)\n    return dest\n\n\ndef print_urls(urls: list[str], request_id: str | None = None) -> None:\n    if not urls:\n        rprint(\"[yellow]No image URLs found in response. Pass --json to inspect.[/yellow]\")\n        return\n    if request_id:\n        rprint(f\"[bold green]Request:[/bold green] {request_id}\")\n    rprint(\"[bold green]Outputs:[/bold green]\")\n    for url in urls:\n        rprint(f\"  {url}\")\n\n\ndef print_json(body: dict | list | str) -> None:\n    if isinstance(body, str):\n        print(body)\n        return\n    print(json.dumps(body, indent=2, default=str))\n\n\ndef print_saved(paths: list[Path]) -> None:\n    if not paths:\n        return\n    rprint(\"[bold green]Saved:[/bold green]\")\n    for p in paths:\n        rprint(f\"  {p}\")\n"
  },
  {
    "path": "comfy_cli/command/generate/poll.py",
    "content": "\"\"\"Async-job polling for partner endpoints.\n\nThere are two flavors:\n\n1. **BFL** — the server returns ``{id, polling_url}`` on submit and we just GET\n   that URL until the ``status`` field is terminal.\n2. **Everything else** — a small ``PollSpec`` per partner describes where the\n   job id lives in the create response, how to construct the poll URL (some\n   partners use a sibling endpoint relative to the create path; others have a\n   dedicated ``/tasks/{id}`` endpoint), and which status values mean\n   \"succeeded\" / \"failed\".\n\nThe generic poller walks dot-paths into the JSON to extract the id/status\nwithout having to write a new adapter for each partner.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport time\nfrom collections.abc import Callable\nfrom dataclasses import dataclass, field\nfrom typing import Any\n\nimport httpx\n\nfrom comfy_cli.command.generate import client\n\n\n@dataclass\nclass PollResult:\n    \"\"\"Normalized terminal state of an async job.\"\"\"\n\n    status: str  # \"succeeded\" | \"failed\" | \"cancelled\"\n    raw: dict[str, Any]  # last response body — full upstream payload\n    image_urls: list[str]  # any image/video result URLs we could pluck out\n    error: str | None = None\n\n\n# Recognized result extensions when sniffing URLs out of a poll body.\n_MEDIA_EXTS = (\n    \".png\",\n    \".jpg\",\n    \".jpeg\",\n    \".webp\",\n    \".gif\",\n    \".svg\",\n    \".mp4\",\n    \".mov\",\n    \".webm\",\n    \".m4v\",\n    \".gltf\",\n    \".glb\",\n    \".obj\",\n    \".fbx\",\n    \".wav\",\n    \".mp3\",\n    \".m4a\",\n    \".flac\",\n)\n\n\ndef _now() -> float:\n    return time.monotonic()\n\n\ndef _extract_urls(node: Any) -> list[str]:\n    \"\"\"Walk a JSON tree, collecting strings that look like media URLs.\"\"\"\n    found: list[str] = []\n\n    def visit(n: Any) -> None:\n        if isinstance(n, str):\n            low = n.lower()\n            if n.startswith((\"http://\", \"https://\")) and (\n                low.split(\"?\", 1)[0].endswith(_MEDIA_EXTS) or \"image\" in low or \"video\" in low\n            ):\n                found.append(n)\n            return\n        if isinstance(n, dict):\n            for v in n.values():\n                visit(v)\n        elif isinstance(n, list):\n            for v in n:\n                visit(v)\n\n    visit(node)\n    seen: set[str] = set()\n    return [u for u in found if not (u in seen or seen.add(u))]\n\n\ndef _dotget(body: Any, path: str) -> Any:\n    \"\"\"Look up a dotted path inside a JSON body. Returns None if any segment misses.\"\"\"\n    cur: Any = body\n    for part in path.split(\".\"):\n        if isinstance(cur, dict):\n            cur = cur.get(part)\n        else:\n            return None\n    return cur\n\n\ndef _first(body: Any, paths: tuple[str, ...]) -> Any:\n    for p in paths:\n        v = _dotget(body, p)\n        if v not in (None, \"\", []):\n            return v\n    return None\n\n\ndef _sleep(seconds: float) -> None:\n    time.sleep(seconds)\n\n\ndef poll_bfl(\n    initial: dict[str, Any],\n    api_key: str,\n    *,\n    interval: float = 2.0,\n    timeout: float = 300.0,\n    on_progress: Callable[[float], None] | None = None,\n    create_path: str | None = None,  # ignored, kept for uniform signature\n) -> PollResult:\n    \"\"\"BFL polls a server-issued ``polling_url`` until ``status`` flips to Ready.\"\"\"\n    url = initial.get(\"polling_url\")\n    if not url:\n        raise client.ApiError(0, \"\", \"BFL response missing polling_url\")\n    deadline = _now() + timeout\n    last_body: dict[str, Any] = {}\n    while _now() < deadline:\n        resp = client.get(url, api_key=api_key)\n        if resp.status_code >= 400:\n            client.raise_for_status(resp)\n        last_body = resp.json()\n        status = str(last_body.get(\"status\", \"\")).strip()\n        if on_progress is not None:\n            progress = last_body.get(\"progress\")\n            if isinstance(progress, int | float):\n                on_progress(float(progress))\n        if status == \"Ready\":\n            urls = _extract_urls(last_body.get(\"result\"))\n            return PollResult(status=\"succeeded\", raw=last_body, image_urls=urls)\n        if status in {\"Error\", \"Task not found\", \"Content Moderated\", \"Request Moderated\"}:\n            return PollResult(status=\"failed\", raw=last_body, image_urls=[], error=status)\n        _sleep(interval)\n    return PollResult(status=\"failed\", raw=last_body, image_urls=[], error=f\"timed out after {timeout:.0f}s\")\n\n\n@dataclass(frozen=True)\nclass PollSpec:\n    \"\"\"Per-partner polling configuration.\n\n    ``poll_url`` is a template supporting ``{id}`` and ``{create_path}``;\n    ``post_success_url`` (optional) is a second-stage fetcher invoked once the\n    job reaches a success state — for partners like MiniMax where the terminal\n    poll response gives you a file id you still need to redeem for a URL.\"\"\"\n\n    name: str\n    id_paths: tuple[str, ...]\n    poll_url: str\n    status_paths: tuple[str, ...]\n    success_values: tuple[str, ...]\n    failure_values: tuple[str, ...] = ()\n    progress_path: str | None = None\n    post_success_url: str | None = None\n    post_success_id_paths: tuple[str, ...] = field(default_factory=tuple)\n\n\n_POLL_SPECS: dict[str, PollSpec] = {\n    \"kling\": PollSpec(\n        name=\"kling\",\n        id_paths=(\"data.task_id\",),\n        poll_url=\"{create_path}/{id}\",\n        status_paths=(\"data.task_status\",),\n        success_values=(\"succeed\",),\n        failure_values=(\"failed\",),\n    ),\n    \"luma\": PollSpec(\n        name=\"luma\",\n        id_paths=(\"id\",),\n        poll_url=\"/proxy/luma/generations/{id}\",\n        status_paths=(\"state\",),\n        success_values=(\"completed\",),\n        failure_values=(\"failed\",),\n    ),\n    \"minimax\": PollSpec(\n        name=\"minimax\",\n        id_paths=(\"task_id\",),\n        poll_url=\"/proxy/minimax/query/video_generation?task_id={id}\",\n        status_paths=(\"status\",),\n        success_values=(\"Success\",),\n        failure_values=(\"Fail\",),\n        post_success_url=\"/proxy/minimax/files/retrieve?file_id={id}\",\n        post_success_id_paths=(\"file_id\",),\n    ),\n    \"runway\": PollSpec(\n        name=\"runway\",\n        id_paths=(\"id\",),\n        poll_url=\"/proxy/runway/tasks/{id}\",\n        status_paths=(\"status\",),\n        success_values=(\"SUCCEEDED\",),\n        failure_values=(\"FAILED\", \"CANCELLED\", \"THROTTLED\"),\n        progress_path=\"progress\",\n    ),\n    \"moonvalley\": PollSpec(\n        name=\"moonvalley\",\n        id_paths=(\"id\",),\n        poll_url=\"/proxy/moonvalley/prompts/{id}\",\n        status_paths=(\"status\",),\n        success_values=(\"completed\",),\n        failure_values=(\"failed\", \"error\"),\n    ),\n    \"pika\": PollSpec(\n        name=\"pika\",\n        id_paths=(\"video_id\", \"id\"),\n        poll_url=\"/proxy/pika/videos/{id}\",\n        status_paths=(\"status\",),\n        success_values=(\"finished\",),\n        # Pika's enum has no explicit failure state; treat sustained queued/started\n        # as in-progress and rely on `timeout` to surface stalls.\n        failure_values=(),\n        progress_path=\"progress\",\n    ),\n    \"vidu\": PollSpec(\n        name=\"vidu\",\n        id_paths=(\"task_id\",),\n        poll_url=\"/proxy/vidu/tasks/{id}/creations\",\n        status_paths=(\"state\",),\n        success_values=(\"success\",),\n        failure_values=(\"failed\",),\n    ),\n    \"xai_video\": PollSpec(\n        name=\"xai_video\",\n        id_paths=(\"request_id\",),\n        poll_url=\"/proxy/xai/v1/videos/{id}\",\n        status_paths=(\"status\",),\n        success_values=(\"done\",),\n        failure_values=(),\n    ),\n    \"seedance\": PollSpec(\n        name=\"seedance\",\n        id_paths=(\"id\",),\n        poll_url=\"/proxy/byteplus/api/v3/contents/generations/tasks/{id}\",\n        status_paths=(\"status\",),\n        success_values=(\"succeeded\",),\n        failure_values=(\"failed\", \"cancelled\"),\n    ),\n}\n\n\ndef _build_poll_url(spec: PollSpec, job_id: str, create_path: str | None) -> str:\n    url = spec.poll_url.replace(\"{id}\", str(job_id))\n    if \"{create_path}\" in url:\n        if not create_path:\n            raise client.ApiError(0, \"\", f\"{spec.name} poller needs the create path\")\n        url = url.replace(\"{create_path}\", create_path)\n    return url\n\n\ndef poll_generic(\n    initial: dict[str, Any],\n    api_key: str,\n    *,\n    spec: PollSpec,\n    create_path: str | None = None,\n    interval: float = 2.0,\n    timeout: float = 300.0,\n    on_progress: Callable[[float], None] | None = None,\n) -> PollResult:\n    \"\"\"Drive a partner's poll endpoint by reading dot-paths out of the JSON.\n\n    ``initial`` is the create-response body; we pull a job id out of it, build\n    the poll URL from ``spec.poll_url``, and GET it until the status field hits\n    a terminal value. Handles MiniMax-style two-stage flows via\n    ``spec.post_success_url`` (a follow-up GET keyed off something the terminal\n    poll body contains, e.g. ``file_id``).\"\"\"\n    job_id = _first(initial, spec.id_paths)\n    if job_id is None:\n        raise client.ApiError(0, \"\", f\"{spec.name} response missing id (looked for {spec.id_paths})\")\n    url = _build_poll_url(spec, str(job_id), create_path)\n    deadline = _now() + timeout\n    last_body: dict[str, Any] = {}\n    while _now() < deadline:\n        resp = client.get(url, api_key=api_key)\n        if resp.status_code >= 400:\n            client.raise_for_status(resp)\n        last_body = resp.json()\n        if on_progress is not None and spec.progress_path:\n            p = _dotget(last_body, spec.progress_path)\n            if isinstance(p, int | float):\n                # Some partners report 0–100, others 0–1; normalize.\n                on_progress(float(p) / 100.0 if p > 1 else float(p))\n        status = _first(last_body, spec.status_paths)\n        status_str = str(status) if status is not None else \"\"\n        if status_str in spec.success_values:\n            merged = dict(last_body)\n            if spec.post_success_url:\n                redeem_id = _first(last_body, spec.post_success_id_paths)\n                if redeem_id is not None:\n                    redeem_url = spec.post_success_url.replace(\"{id}\", str(redeem_id))\n                    r2 = client.get(redeem_url, api_key=api_key)\n                    if r2.status_code < 400:\n                        try:\n                            merged[\"_redeemed\"] = r2.json()\n                        except ValueError:\n                            pass\n            urls = _extract_urls(merged)\n            return PollResult(status=\"succeeded\", raw=merged, image_urls=urls)\n        if status_str in spec.failure_values:\n            return PollResult(status=\"failed\", raw=last_body, image_urls=[], error=status_str)\n        _sleep(interval)\n    return PollResult(status=\"failed\", raw=last_body, image_urls=[], error=f\"timed out after {timeout:.0f}s\")\n\n\ndef extract_job_id(name: str, body: dict[str, Any]) -> str | None:\n    \"\"\"Pull the partner's job id out of a create-response body for display.\"\"\"\n    if name == \"bfl\":\n        return body.get(\"id\") or None\n    spec = _POLL_SPECS.get(name)\n    if spec is None:\n        return None\n    v = _first(body, spec.id_paths)\n    return str(v) if v is not None else None\n\n\ndef build_synthetic_initial(name: str, job_id: str, base_url: str | None = None) -> dict[str, Any]:\n    \"\"\"Recreate a minimal create-response so ``poll_generic`` can find the id.\n\n    Used by ``comfy generate resume`` — the user supplies just a partner key\n    and a job id, and we reverse-engineer the shape the poller expects.\"\"\"\n    if name == \"bfl\":\n        if not base_url:\n            raise client.ApiError(0, \"\", \"BFL resume needs a base URL to build the polling_url\")\n        return {\"polling_url\": f\"{base_url}/proxy/bfl/get_result?id={job_id}\"}\n    spec = _POLL_SPECS.get(name)\n    if not spec:\n        raise client.ApiError(0, \"\", f\"No polling adapter for partner {name!r}\")\n    primary = spec.id_paths[0]\n    body: dict[str, Any] = {}\n    cur = body\n    parts = primary.split(\".\")\n    for p in parts[:-1]:\n        cur[p] = {}\n        cur = cur[p]\n    cur[parts[-1]] = job_id\n    return body\n\n\ndef get_poller(name: str) -> Callable[..., PollResult]:\n    \"\"\"Return the poller callable for a partner name.\n\n    All pollers accept the same kwargs (``api_key``, ``timeout``, ``on_progress``,\n    ``create_path``) so callers don't need to special-case which one they got.\"\"\"\n    if name == \"bfl\":\n        return poll_bfl\n    if name in _POLL_SPECS:\n        spec = _POLL_SPECS[name]\n\n        def runner(\n            initial: dict[str, Any],\n            api_key: str,\n            *,\n            create_path: str | None = None,\n            interval: float = 2.0,\n            timeout: float = 300.0,\n            on_progress: Callable[[float], None] | None = None,\n        ) -> PollResult:\n            return poll_generic(\n                initial,\n                api_key,\n                spec=spec,\n                create_path=create_path,\n                interval=interval,\n                timeout=timeout,\n                on_progress=on_progress,\n            )\n\n        return runner\n    raise client.ApiError(0, \"\", f\"No polling adapter for partner {name!r}\")\n\n\ndef sync_result_from_response(resp: httpx.Response) -> PollResult:\n    \"\"\"Wrap a sync response in a PollResult so the run path is uniform.\"\"\"\n    ctype = resp.headers.get(\"content-type\", \"\")\n    if ctype.startswith((\"image/\", \"video/\", \"audio/\")):\n        return PollResult(status=\"succeeded\", raw={\"_binary\": True}, image_urls=[])\n    try:\n        body = resp.json()\n    except ValueError:\n        return PollResult(status=\"succeeded\", raw={\"_text\": resp.text}, image_urls=[])\n    return PollResult(status=\"succeeded\", raw=body, image_urls=_extract_urls(body))\n"
  },
  {
    "path": "comfy_cli/command/generate/schema.py",
    "content": "\"\"\"Convert an openapi requestBody schema into CLI flag definitions, and parse\nuser-supplied argv against those flags.\n\nThis is the equivalent of fal-ai's `genmedia run <id> --param value` UX: the\nschema for each endpoint drives which flags are valid, their types, and their\nhelp text. We accept inline JSON for object/array params and treat fields with\n``format: binary`` as file-path inputs that get streamed via multipart.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport shlex\nfrom dataclasses import dataclass, field\nfrom pathlib import Path\nfrom typing import Any\n\nfrom comfy_cli.command.generate.spec import Endpoint\n\n\n@dataclass\nclass FlagDef:\n    name: str  # openapi property name; CLI flag = \"--\" + name\n    kind: str  # \"string\" | \"integer\" | \"number\" | \"boolean\" | \"enum\" | \"object\" | \"array\" | \"binary\"\n    required: bool\n    description: str = \"\"\n    default: Any = None\n    enum: list[str] = field(default_factory=list)\n    item_kind: str | None = None  # for arrays: kind of items (\"binary\", \"string\", ...)\n    upload_mode: str | None = None  # \"base64\" | \"url\" | None — auto-upload behavior for local paths\n\n\nclass SchemaError(ValueError):\n    pass\n\n\ndef _classify(prop: dict[str, Any]) -> tuple[str, str | None]:\n    \"\"\"Return (kind, item_kind). item_kind only set when kind == 'array'.\"\"\"\n    if \"enum\" in prop and prop.get(\"type\", \"string\") == \"string\":\n        return \"enum\", None\n    t = prop.get(\"type\")\n    if t == \"string\" and prop.get(\"format\") == \"binary\":\n        return \"binary\", None\n    if t == \"string\":\n        return \"string\", None\n    if t == \"integer\":\n        return \"integer\", None\n    if t == \"number\":\n        return \"number\", None\n    if t == \"boolean\":\n        return \"boolean\", None\n    if t == \"array\":\n        items = prop.get(\"items\") or {}\n        if items.get(\"format\") == \"binary\":\n            return \"array\", \"binary\"\n        return \"array\", items.get(\"type\", \"string\")\n    if t == \"object\" or \"oneOf\" in prop or \"anyOf\" in prop or \"allOf\" in prop:\n        return \"object\", None\n    # Fallback — treat as string.\n    return \"string\", None\n\n\n_URL_FIELD_NAMES = frozenset({\"input_image\", \"image_url\", \"image_uri\", \"init_image\", \"reference_image\", \"mask_url\"})\n\n\ndef _detect_upload_mode(name: str, prop: dict[str, Any]) -> str | None:\n    \"\"\"Infer whether this string param expects a base64 blob, a URL, or neither.\n\n    JSON-only endpoints sometimes ship a local file via base64 (BFL Kontext,\n    Flux Fill/Canny/Depth/Expand all say \"Base64 encoded image\" in their\n    descriptions) and sometimes via a signed URL. We sniff three signals in\n    priority order: openapi ``format``, description keywords, then a small\n    allow-list of common field names.\"\"\"\n    if prop.get(\"type\") != \"string\":\n        return None\n    desc = (prop.get(\"description\") or \"\").lower()\n    fmt = (prop.get(\"format\") or \"\").lower()\n    if \"base64\" in desc:\n        return \"base64\"\n    if fmt in {\"uri\", \"url\"}:\n        return \"url\"\n    if \"url\" in desc or \"uri\" in desc:\n        return \"url\"\n    if name.lower() in _URL_FIELD_NAMES:\n        return \"url\"\n    return None\n\n\ndef flags_for(endpoint: Endpoint) -> list[FlagDef]:\n    # Lazy import: adapters depends on FlagDef, so the top-level import would cycle.\n    from comfy_cli.command.generate import adapters as _adapters\n\n    adapter = _adapters.get(endpoint.id)\n    if adapter is not None:\n        return list(adapter.flags)\n    schema = endpoint.request_schema or {}\n    props = schema.get(\"properties\") or {}\n    required = set(schema.get(\"required\") or [])\n    out: list[FlagDef] = []\n    for name, prop in props.items():\n        if not isinstance(prop, dict):\n            continue\n        kind, item_kind = _classify(prop)\n        upload_mode = _detect_upload_mode(name, prop) if kind == \"string\" else None\n        out.append(\n            FlagDef(\n                name=name,\n                kind=kind,\n                required=name in required,\n                description=str(prop.get(\"description\") or \"\").strip(),\n                default=prop.get(\"default\"),\n                enum=list(prop.get(\"enum\") or []),\n                item_kind=item_kind,\n                upload_mode=upload_mode,\n            )\n        )\n    return out\n\n\ndef _coerce(flag: FlagDef, raw: str) -> Any:\n    \"\"\"Convert a string value from argv into its typed form, raising SchemaError\n    with a clear message on failure.\"\"\"\n    if flag.kind == \"string\":\n        return raw\n    if flag.kind == \"enum\":\n        if flag.enum and raw not in flag.enum:\n            raise SchemaError(f\"--{flag.name}: {raw!r} is not one of {flag.enum}\")\n        return raw\n    if flag.kind == \"integer\":\n        try:\n            return int(raw)\n        except ValueError as e:\n            raise SchemaError(f\"--{flag.name}: expected integer, got {raw!r}\") from e\n    if flag.kind == \"number\":\n        try:\n            return float(raw)\n        except ValueError as e:\n            raise SchemaError(f\"--{flag.name}: expected number, got {raw!r}\") from e\n    if flag.kind == \"boolean\":\n        v = raw.lower()\n        if v in {\"true\", \"1\", \"yes\", \"on\"}:\n            return True\n        if v in {\"false\", \"0\", \"no\", \"off\"}:\n            return False\n        raise SchemaError(f\"--{flag.name}: expected boolean (true/false), got {raw!r}\")\n    if flag.kind in (\"object\", \"array\"):\n        # For arrays of binary, the raw value is a comma-separated list of paths\n        # or a JSON array of paths.\n        if flag.kind == \"array\" and flag.item_kind == \"binary\":\n            try:\n                parsed = json.loads(raw) if raw.startswith(\"[\") else [p.strip() for p in raw.split(\",\")]\n            except json.JSONDecodeError as e:\n                raise SchemaError(f\"--{flag.name}: invalid file list: {e}\") from e\n            return [Path(p).expanduser() for p in parsed]\n        try:\n            return json.loads(raw)\n        except json.JSONDecodeError as e:\n            raise SchemaError(\n                f\"--{flag.name}: expected JSON {flag.kind}, got {raw!r}. \"\n                'Wrap the value in quotes, e.g. --{n} \\'{{\"k\":\"v\"}}\\''.format(n=flag.name)\n            ) from e\n    if flag.kind == \"binary\":\n        return Path(raw).expanduser()\n    raise SchemaError(f\"--{flag.name}: unknown kind {flag.kind!r}\")  # unreachable\n\n\ndef parse_args(flags: list[FlagDef], argv: list[str]) -> dict[str, Any]:\n    \"\"\"Parse ``argv`` against the given flag list. Returns {name: typed_value}.\n\n    Recognized forms:\n      --name value\n      --name=value\n      --name           (only for boolean flags; means True)\n      --no-name        (only for boolean flags; means False)\n    \"\"\"\n    by_name = {f.name: f for f in flags}\n    # Also accept dash-separated aliases so `--no-X` matching can't collide with\n    # underscored names.\n    by_dashed = {f.name.replace(\"_\", \"-\"): f for f in flags}\n\n    values: dict[str, Any] = {}\n    i = 0\n    while i < len(argv):\n        token = argv[i]\n        if not token.startswith(\"--\"):\n            raise SchemaError(f\"Unexpected positional argument: {token!r}\")\n        body = token[2:]\n        raw: str | None = None\n        if \"=\" in body:\n            body, raw = body.split(\"=\", 1)\n        flag = by_name.get(body) or by_dashed.get(body)\n        if flag is None and body.startswith(\"no-\"):\n            candidate = body[3:]\n            f = by_name.get(candidate) or by_dashed.get(candidate)\n            if f and f.kind == \"boolean\":\n                values[f.name] = False\n                i += 1\n                continue\n        if flag is None:\n            raise SchemaError(f\"Unknown flag: {token!r}. Run `comfy generate schema <model>` to list params.\")\n        if flag.kind == \"boolean\" and raw is None:\n            # Look ahead — only consume next token if it parses as boolean.\n            if i + 1 < len(argv) and argv[i + 1].lower() in {\"true\", \"false\", \"1\", \"0\", \"yes\", \"no\", \"on\", \"off\"}:\n                raw = argv[i + 1]\n                i += 1\n            else:\n                values[flag.name] = True\n                i += 1\n                continue\n        if raw is None:\n            if i + 1 >= len(argv):\n                raise SchemaError(f\"--{flag.name}: missing value\")\n            raw = argv[i + 1]\n            i += 2\n        else:\n            i += 1\n        values[flag.name] = _coerce(flag, raw)\n\n    missing = [f.name for f in flags if f.required and f.name not in values]\n    if missing:\n        joined = \", \".join(f\"--{m}\" for m in missing)\n        raise SchemaError(f\"Missing required argument(s): {joined}\")\n    return values\n\n\ndef help_text(endpoint: Endpoint, flags: list[FlagDef]) -> str:\n    \"\"\"Produce a human-readable help block describing a model and its flags.\n    The caller is expected to already print a one-line ``Model:`` header, so we\n    skip restating the id here.\"\"\"\n    lines: list[str | None] = [\n        f\"  {endpoint.summary}\" if endpoint.summary else None,\n        f\"  partner: {endpoint.partner}    style: {endpoint.category}    \"\n        f\"content-type: {endpoint.request_content_type}    \"\n        f\"mode: {'async (' + endpoint.polling + ')' if endpoint.polling else 'sync'}\",\n        \"\",\n        \"Parameters (use as `--name value`):\",\n    ]\n    if not flags:\n        lines.append(\"  (no parameters)\")\n    for f in flags:\n        marker = \"  *\" if f.required else \"   \"\n        type_str = f.kind\n        if f.kind == \"enum\":\n            type_str = \"enum=\" + \"|\".join(f.enum)\n        if f.kind == \"array\" and f.item_kind:\n            type_str = f\"array<{f.item_kind}>\"\n        head = f\"{marker} --{f.name} <{type_str}>\"\n        lines.append(head)\n        if f.description:\n            lines.append(f\"      {f.description}\")\n        if f.default is not None:\n            lines.append(f\"      default: {f.default!r}\")\n    lines.append(\"\")\n    lines.append(\"Common options:\")\n    lines.append(\"  --download <path>  Save outputs locally. Supports {request_id}, {index}, {ext}.\")\n    lines.append(\"  --async            Submit and return job id without waiting.\")\n    lines.append(\"  --json             Emit raw JSON response instead of pretty output.\")\n    lines.append(\"  --timeout <sec>    Override sync-poll timeout (default 300).\")\n    lines.append(\"  --api-key <key>    Override COMFY_API_KEY env var.\")\n    return \"\\n\".join(line for line in lines if line is not None)\n\n\ndef example_invocation(endpoint: Endpoint, flags: list[FlagDef], display_name: str | None = None) -> str:\n    \"\"\"A copy-paste invocation snippet showing required args.\"\"\"\n    parts = [\"comfy generate\", display_name or endpoint.id]\n    for f in flags:\n        if not f.required:\n            continue\n        if f.kind == \"binary\":\n            parts.extend([f\"--{f.name}\", \"./input.png\"])\n        elif f.kind == \"enum\":\n            parts.extend([f\"--{f.name}\", f.enum[0] if f.enum else \"VALUE\"])\n        elif f.kind == \"string\":\n            parts.extend([f\"--{f.name}\", shlex.quote(\"...\")])\n        elif f.kind in (\"object\", \"array\"):\n            parts.extend([f\"--{f.name}\", shlex.quote(\"{}\")])\n        else:\n            parts.extend([f\"--{f.name}\", \"0\"])\n    return \" \".join(parts)\n"
  },
  {
    "path": "comfy_cli/command/generate/spec/openapi.yml",
    "content": "openapi: \"3.0.2\"\ninfo:\n  title: Comfy API\n  version: \"1.0\"\nservers:\n  - url: https://api.comfy.org\npaths:\n  /users:\n    get:\n      summary: Get information about the calling user.\n      operationId: getUser\n      tags:\n        - Registry\n      security:\n        - BearerAuth: []\n      responses:\n        \"200\":\n          description: OK\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/User\"\n\n        \"404\":\n          description: Not Found\n        \"401\":\n          description: Unauthorized\n  /customers:\n    post:\n      summary: Create a new customer\n      description: Creates a new customer using the provided token. No request body is needed as user information is extracted from the token.\n      operationId: createCustomer\n      x-excluded: true\n      tags:\n        - API Nodes\n      security:\n        - BearerAuth: []\n      responses:\n        \"200\":\n          description: Customer already exists\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Customer\"\n        \"201\":\n          description: Customer created successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Customer\"\n        \"400\":\n          description: Invalid request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n    get:\n      summary: Search for customers\n      description: Search for customers by email, name, Stripe ID, or Metronome ID.\n      operationId: searchCustomers\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Admin\n      security:\n        - BearerAuth: []\n      parameters:\n        - in: query\n          name: email\n          schema:\n            type: string\n          description: Email address to search for\n        - in: query\n          name: name\n          schema:\n            type: string\n          description: Customer name to search for\n        - in: query\n          name: stripe_id\n          schema:\n            type: string\n          description: Stripe customer ID to search for\n        - in: query\n          name: metronome_id\n          schema:\n            type: string\n          description: Metronome customer ID to search for\\\n        - in: query\n          name: page\n          schema:\n            type: integer\n            default: 1\n          description: Page number to retrieve\n        - in: query\n          name: limit\n          schema:\n            type: integer\n            default: 10\n          description: Number of customers to return per page\n      responses:\n        \"200\":\n          description: Customers matching the search criteria\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  page:\n                    type: integer\n                    description: Current page number\n                  limit:\n                    type: integer\n                    description: Number of customers per page\n                  totalPages:\n                    type: integer\n                    description: Total number of pages available\n                  customers:\n                    type: array\n                    items:\n                      $ref: \"#/components/schemas/Customer\"\n                  total:\n                    type: integer\n                    description: Total number of matching customers\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden - insufficient permissions\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /customers/me:\n    get:\n      summary: Get authenticated customer details\n      description: Returns details about the currently authenticated customer based on their JWT token.\n      operationId: getAuthenticatedCustomer\n      x-excluded: true\n      tags:\n        - API Nodes\n      security:\n        - BearerAuth: []\n      responses:\n        \"200\":\n          description: Customer details retrieved successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Customer\"\n        \"401\":\n          description: Unauthorized or invalid token\n        \"404\":\n          description: Customer not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /customers/{customer_id}:\n    get:\n      summary: Get a customer by ID\n      description: Returns details about a customer by their ID.\n      operationId: getCustomerById\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Admin\n      security:\n        - BearerAuth: []\n      parameters:\n        - in: path\n          name: customer_id\n          required: true\n          schema:\n            type: string\n      responses:\n        \"200\":\n          description: Customer details retrieved successfully\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  customer:\n                    $ref: \"#/components/schemas/CustomerAdmin\"\n        \"401\":\n          description: Unauthorized or invalid token\n        \"404\":\n          description: Customer not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /customers/api-keys:\n    get:\n      summary: List all API keys for a customer\n      operationId: listCustomerAPIKeys\n      x-excluded: true\n      responses:\n        \"200\":\n          description: List of API keys\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  api_keys:\n                    type: array\n                    items:\n                      $ref: \"#/components/schemas/APIKey\"\n        \"401\":\n          description: Unauthorized\n        \"404\":\n          description: Customer not found\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n    post:\n      summary: Create a new API key for a customer\n      operationId: createCustomerAPIKey\n      x-excluded: true\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/CreateAPIKeyRequest\"\n      responses:\n        \"201\":\n          description: API key created\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  api_key:\n                    $ref: \"#/components/schemas/APIKeyWithPlaintext\"\n        \"400\":\n          description: Bad request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"404\":\n          description: Customer or API key not found\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /customers/api-keys/{api_key_id}:\n    delete:\n      summary: Delete an API key for a customer\n      operationId: deleteCustomerAPIKey\n      x-excluded: true\n      parameters:\n        - in: path\n          name: api_key_id\n          required: true\n          schema:\n            type: string\n      responses:\n        \"204\":\n          description: API key deleted\n        \"401\":\n          description: Unauthorized\n        \"404\":\n          description: Customer or API key not found\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /customers/credit:\n    post:\n      summary: Initiates a Credit Purchase.\n      operationId: InitiateCreditPurchase\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                amount_micros:\n                  type: integer\n                  format: int64\n                  description: the amount of the checkout transaction in micro value\n                currency:\n                  type: string\n                  description: the currency used in the checkout transaction\n              required:\n                - amount_micros\n                - currency\n      responses:\n        \"201\":\n          description: Customer Checkout created successfully\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  checkout_url:\n                    type: string\n                    description: the url to redirect the customer\n        \"400\":\n          description: Bad request, invalid token or user already exists\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized or invalid token\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /customers/billing:\n    post:\n      summary: Access customer billing portal\n      description: Creates a session for the customer to access their billing portal where they can manage subscriptions, payment methods, and view invoices.\n      operationId: AccessBillingPortal\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: false\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                return_url:\n                  type: string\n                  description: Optional URL to redirect the customer after they're done with the billing portal\n                target_tier:\n                  type: string\n                  enum: [standard, creator, pro, standard-yearly, creator-yearly, pro-yearly]\n                  description: Optional target subscription tier. When provided, creates a deep link directly to the subscription update confirmation screen with this tier pre-selected.\n      responses:\n        \"200\":\n          description: Billing portal session created successfully\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  billing_portal_url:\n                    type: string\n                    description: The URL to redirect the customer to the billing portal\n        \"400\":\n          description: Bad request, invalid input\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized or invalid token\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /customers/cloud-subscription-checkout:\n    post:\n      summary: Create cloud subscription checkout session\n      description: Creates a cloud subscription checkout session for $20/month with automatic billing\n      operationId: createCloudSubscriptionCheckout\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: false\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                ga_client_id:\n                  type: string\n                  description: Google Analytics client ID from _ga cookie\n                ga_session_id:\n                  type: string\n                  description: Google Analytics session ID\n                ga_session_number:\n                  type: string\n                  description: Google Analytics session number\n                gclid:\n                  type: string\n                  description: Google Ads click ID\n                gbraid:\n                  type: string\n                  description: Google Ads iOS attribution parameter\n                wbraid:\n                  type: string\n                  description: Google Ads web-to-app attribution parameter\n                utm_source:\n                  type: string\n                  description: UTM source parameter\n                utm_medium:\n                  type: string\n                  description: UTM medium parameter\n                utm_campaign:\n                  type: string\n                  description: UTM campaign parameter\n                utm_term:\n                  type: string\n                  description: UTM term parameter\n                utm_content:\n                  type: string\n                  description: UTM content parameter\n                im_ref:\n                  type: string\n                  description: Impact.com click ID for affiliate conversion tracking\n      responses:\n        \"201\":\n          description: Subscription checkout session created successfully\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  checkout_url:\n                    type: string\n                    description: The URL to redirect the customer to complete subscription\n        \"400\":\n          description: Bad request, invalid input\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized or invalid token\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /customers/cloud-subscription-checkout/{tier}:\n    post:\n      summary: Create cloud subscription checkout session for a specific tier\n      description: Creates a cloud subscription checkout session for a specific subscription tier (standard, creator, or pro) with automatic billing\n      operationId: createCloudSubscriptionCheckoutTier\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      parameters:\n        - in: path\n          name: tier\n          required: true\n          description: The subscription tier (standard, creator, or pro) with optional yearly billing (standard-yearly, creator-yearly, pro-yearly)\n          schema:\n            type: string\n            enum:\n              - standard\n              - creator\n              - pro\n              - standard-yearly\n              - creator-yearly\n              - pro-yearly\n      requestBody:\n        required: false\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                ga_client_id:\n                  type: string\n                  description: Google Analytics client ID from _ga cookie\n                ga_session_id:\n                  type: string\n                  description: Google Analytics session ID\n                ga_session_number:\n                  type: string\n                  description: Google Analytics session number\n                gclid:\n                  type: string\n                  description: Google Ads click ID\n                gbraid:\n                  type: string\n                  description: Google Ads iOS attribution parameter\n                wbraid:\n                  type: string\n                  description: Google Ads web-to-app attribution parameter\n                utm_source:\n                  type: string\n                  description: UTM source parameter\n                utm_medium:\n                  type: string\n                  description: UTM medium parameter\n                utm_campaign:\n                  type: string\n                  description: UTM campaign parameter\n                utm_term:\n                  type: string\n                  description: UTM term parameter\n                utm_content:\n                  type: string\n                  description: UTM content parameter\n                im_ref:\n                  type: string\n                  description: Impact.com click ID for affiliate conversion tracking\n      responses:\n        \"201\":\n          description: Subscription checkout session created successfully\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  checkout_url:\n                    type: string\n                    description: The URL to redirect the customer to complete subscription\n        \"400\":\n          description: Bad request, invalid input or tier\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized or invalid token\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /customers/cloud-subscription-status:\n    get:\n      summary: Check cloud subscription status\n      description: Check if the customer has an active cloud subscription\n      operationId: GetCloudSubscriptionStatus\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      responses:\n        \"200\":\n          description: Cloud subscription status retrieved successfully\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  is_active:\n                    type: boolean\n                    description: Whether the customer has an active cloud subscription\n                  subscription_id:\n                    type: string\n                    description: The active subscription ID if one exists\n                    nullable: true\n                  subscription_tier:\n                    allOf:\n                      - $ref: \"#/components/schemas/SubscriptionTier\"\n                    nullable: true\n                  subscription_duration:\n                    allOf:\n                      - $ref: \"#/components/schemas/SubscriptionDuration\"\n                    nullable: true\n                  has_fund:\n                    type: boolean\n                    description: Whether the customer has funds/credits available\n                  renewal_date:\n                    type: string\n                    format: date-time\n                    description: The next renewal date for the subscription (ISO 8601 format)\n                    nullable: true\n                  end_date:\n                    type: string\n                    format: date-time\n                    description: The date when the subscription is set to end (ISO 8601 format)\n                    nullable: true\n        \"401\":\n          description: Unauthorized or invalid token\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /admin/verify-api-key:\n    post:\n      summary: Verify a ComfyUI API key and return customer details\n      description: |\n        Validates a ComfyUI API key and returns the associated customer information.\n        This endpoint is used by cloud.comfy.org to authenticate users via API keys\n        instead of Firebase tokens.\n      operationId: VerifyApiKey\n      x-excluded: true\n      tags:\n        - Admin\n      parameters:\n        - in: header\n          name: X-Comfy-Admin-Secret\n          required: true\n          schema:\n            type: string\n          description: Admin API secret used to authorize this request\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                api_key:\n                  type: string\n                  description: The ComfyUI API key to verify (e.g., comfy_xxx...)\n              required:\n                - api_key\n      responses:\n        \"200\":\n          description: API key is valid\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  valid:\n                    type: boolean\n                    description: Whether the API key is valid\n                  firebase_uid:\n                    type: string\n                    description: The Firebase UID of the user\n                  email:\n                    type: string\n                    description: The customer's email address\n                  name:\n                    type: string\n                    description: The customer's name\n                  is_admin:\n                    type: boolean\n                    description: Whether the customer is an admin\n                required:\n                  - valid\n                  - firebase_uid\n        \"401\":\n          description: Unauthorized or missing admin API secret\n        \"403\":\n          description: API key auth not allowed for this account (e.g., free tier)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: API key not found or invalid\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /admin/generate-token:\n    post:\n      summary: Generate a short-lived JWT admin token\n      description: |\n        Generates a short-lived JWT admin token for browser-based admin operations.\n        The user must already be authenticated with Firebase and have admin privileges.\n        The generated token expires after 1 hour.\n      operationId: GenerateAdminToken\n      tags:\n        - Admin\n      security:\n        - BearerAuth: []\n      responses:\n        \"200\":\n          description: JWT token generated successfully\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  token:\n                    type: string\n                    description: The JWT admin token\n                  expires_at:\n                    type: string\n                    format: date-time\n                    description: When the token expires\n                required:\n                  - token\n                  - expires_at\n        \"401\":\n          description: Unauthorized or user is not an admin\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /admin/customers/{customer_id}/cloud-subscription-status:\n    get:\n      summary: Admin check cloud subscription status\n      description: Allows an admin to inspect a specific customer's cloud subscription status.\n      operationId: GetAdminCustomerCloudSubscriptionStatus\n      x-excluded: true\n      tags:\n        - Admin\n      parameters:\n        - in: path\n          name: customer_id\n          required: true\n          schema:\n            type: string\n          description: The ID of the customer whose subscription status to retrieve\n        - in: header\n          name: X-Comfy-Admin-Secret\n          required: true\n          schema:\n            type: string\n          description: Admin API secret used to authorize this request\n      responses:\n        \"200\":\n          description: Cloud subscription status retrieved successfully\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  is_active:\n                    type: boolean\n                    description: Whether the customer has an active cloud subscription\n                  subscription_id:\n                    type: string\n                    description: The active subscription ID if one exists\n                    nullable: true\n                  subscription_tier:\n                    allOf:\n                      - $ref: \"#/components/schemas/SubscriptionTier\"\n                    nullable: true\n                  subscription_duration:\n                    allOf:\n                      - $ref: \"#/components/schemas/SubscriptionDuration\"\n                    nullable: true\n                  has_fund:\n                    type: boolean\n                    description: Whether the customer has funds/credits available\n                  renewal_date:\n                    type: string\n                    format: date-time\n                    description: The next renewal date for the subscription (ISO 8601 format)\n                    nullable: true\n                  end_date:\n                    type: string\n                    format: date-time\n                    description: The date when the subscription is set to end (ISO 8601 format)\n                    nullable: true\n        \"401\":\n          description: Unauthorized or missing admin API secret\n        \"404\":\n          description: Customer not found\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /admin/customers/{customer_id}/balance:\n    get:\n      summary: Admin get customer's remaining balance\n      description: Returns the specified customer's current remaining balance in microamount and its currency.\n      operationId: GetAdminCustomerBalance\n      x-excluded: true\n      tags:\n        - Admin\n      parameters:\n        - in: path\n          name: customer_id\n          required: true\n          schema:\n            type: string\n        - in: header\n          name: X-Comfy-Admin-Secret\n          required: true\n          schema:\n            type: string\n      responses:\n        \"200\":\n          description: Customer balance retrieved successfully\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  amount_micros:\n                    type: number\n                    format: double\n                  prepaid_balance_micros:\n                    type: number\n                    format: double\n                  cloud_credit_balance_micros:\n                    type: number\n                    format: double\n                  pending_charges_micros:\n                    type: number\n                    format: double\n                  effective_balance_micros:\n                    type: number\n                    format: double\n                  currency:\n                    type: string\n                required:\n                  - amount_micros\n                  - currency\n        \"401\":\n          description: Unauthorized\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: Customer not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /admin/customers/{customer_id}/stripe-data:\n    delete:\n      summary: Delete customer Stripe data\n      description: Deletes the Stripe customer data associated with the given customer ID.\n      operationId: DeleteAdminCustomerStripeData\n      x-excluded: true\n      tags:\n        - Admin\n      parameters:\n        - in: path\n          name: customer_id\n          required: true\n          schema:\n            type: string\n          description: The ID of the customer whose Stripe data to delete\n        - in: header\n          name: X-Comfy-Admin-Secret\n          required: true\n          schema:\n            type: string\n          description: Admin API secret used to authorize this request\n      responses:\n        \"200\":\n          description: Stripe data deleted successfully\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  message:\n                    type: string\n                    description: Success message\n        \"400\":\n          description: Bad request - missing required parameter\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized or missing admin API secret\n        \"404\":\n          description: Customer not found\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /admin/customers/{customer_id}/archive-metronome-data:\n    post:\n      summary: Archive customer Metronome data\n      description: Archives metronome data. See https://docs.metronome.com/api-reference/customers/archive-a-customer\n      operationId: PostAdminArchiveMetronomeData\n      x-excluded: true\n      tags:\n        - Admin\n      parameters:\n        - in: path\n          name: customer_id\n          required: true\n          schema:\n            type: string\n          description: The ID of the customer whose Metronome data to archive\n        - in: header\n          name: X-Comfy-Admin-Secret\n          required: true\n          schema:\n            type: string\n          description: Admin API secret used to authorize this request\n      responses:\n        \"200\":\n          description: Metronome data archived successfully\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  message:\n                    type: string\n                    description: Success message\n        \"400\":\n          description: Bad request - missing required parameter\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized or missing admin API secret\n        \"404\":\n          description: Customer not found\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /customers/usage:\n    post:\n      summary: Get customer's usage\n      description: Returns the customer's as a dashboard URL.\n      operationId: GetCustomerUsage\n      x-excluded: true\n      tags:\n        - API Nodes\n      requestBody:\n        required: false\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                dashboard_type:\n                  type: string\n                  description: The type of dashboard to retrieve\n                  enum:\n                    - invoices\n                    - usage\n                    - credits\n                    - commits_and_credits\n                  default: usage\n                color_overrides:\n                  type: array\n                  description: Optional list of colors to override for branding\n                  items:\n                    type: object\n                    required:\n                      - name\n                      - value\n                    properties:\n                      name:\n                        type: string\n                        description: The color property to override\n                        enum:\n                          - Gray_dark\n                          - Gray_medium\n                          - Gray_light\n                          - Gray_extralight\n                          - White\n                          - Primary_medium\n                          - Primary_light\n                          - UsageLine_0\n                          - UsageLine_1\n                          - UsageLine_2\n                          - UsageLine_3\n                          - UsageLine_4\n                          - UsageLine_5\n                          - UsageLine_6\n                          - UsageLine_7\n                          - UsageLine_8\n                          - UsageLine_9\n                          - Primary_green\n                          - Primary_red\n                          - Progress_bar\n                          - Progress_bar_background\n                      value:\n                        type: string\n                        description: Hex color code (e.g., \"#FF5733\")\n                        pattern: \"^#[0-9A-Fa-f]{6}$\"\n      responses:\n        \"200\":\n          description: Successful response\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  url:\n                    type: string\n                    description: The dashboard URL for the customer's usage\n        \"401\":\n          description: Unauthorized or invalid token\n        \"404\":\n          description: Customer not found\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /customers/balance:\n    get:\n      summary: Get customer's remaining balance\n      description: Returns the customer's current remaining balance in microamount and its currency, with separate breakdowns for prepaid commits and cloud credits.\n      operationId: GetCustomerBalance\n      x-excluded: true\n      tags:\n        - API Nodes\n      security:\n        - BearerAuth: []\n      responses:\n        \"200\":\n          description: Customer balance retrieved successfully\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  amount_micros:\n                    type: number\n                    format: double\n                    description: The total remaining balance in microamount (1/1,000,000 of the currency unit)\n                  prepaid_balance_micros:\n                    type: number\n                    format: double\n                    description: The remaining balance from prepaid commits in microamount\n                  cloud_credit_balance_micros:\n                    type: number\n                    format: double\n                    description: The remaining balance from cloud credits in microamount\n                  pending_charges_micros:\n                    type: number\n                    format: double\n                    description: The total amount of pending/unbilled charges from draft invoices in microamount. Only included when the show_negative_balances feature flag is enabled.\n                  effective_balance_micros:\n                    type: number\n                    format: double\n                    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.\n                  currency:\n                    type: string\n                    description: The currency code (e.g., \"usd\")\n                required:\n                  - amount_micros\n                  - currency\n        \"401\":\n          description: Unauthorized or invalid token\n        \"404\":\n          description: Customer not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /customers/{customer_id}/balance:\n    get:\n      summary: Get customer's remaining balance by ID\n      description: Returns the specified customer's current remaining balance in microamount and its currency, with separate breakdowns for prepaid commits and cloud credits.\n      operationId: GetCustomerBalanceById\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Admin\n      security:\n        - BearerAuth: []\n      parameters:\n        - in: path\n          name: customer_id\n          required: true\n          schema:\n            type: string\n          description: The ID of the customer whose balance to retrieve\n      responses:\n        \"200\":\n          description: Customer balance retrieved successfully\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  amount_micros:\n                    type: number\n                    format: double\n                    description: The total remaining balance in microamount (1/1,000,000 of the currency unit)\n                  prepaid_balance_micros:\n                    type: number\n                    format: double\n                    description: The remaining balance from prepaid commits in microamount\n                  cloud_credit_balance_micros:\n                    type: number\n                    format: double\n                    description: The remaining balance from cloud credits in microamount\n                  pending_charges_micros:\n                    type: number\n                    format: double\n                    description: The total amount of pending/unbilled charges from draft invoices in microamount. Only included when the show_negative_balances feature flag is enabled.\n                  effective_balance_micros:\n                    type: number\n                    format: double\n                    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.\n                  currency:\n                    type: string\n                    description: The currency code (e.g., \"usd\")\n                required:\n                  - amount_micros\n                  - currency\n        \"400\":\n          description: Bad request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized or invalid token\n        \"404\":\n          description: Customer not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /customers/{customer_id}/usage:\n    post:\n      summary: Track usage for a customer (Admin only)\n      description: Manually track usage for a customer in Metronome. This endpoint is for admin use to record usage events.\n      operationId: TrackCustomerUsage\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Admin\n      security:\n        - BearerAuth: []\n      parameters:\n        - in: path\n          name: customer_id\n          required: true\n          schema:\n            type: string\n          description: The ID of the customer to track usage for\n        - in: header\n          name: X-Comfy-Admin-Secret\n          required: true\n          schema:\n            type: string\n          description: Admin API secret used to authorize this request\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                transaction_id:\n                  type: string\n                  format: uuid\n                  description: Unique transaction ID for this usage event\n                timestamp:\n                  type: string\n                  format: date-time\n                  description: Timestamp of the usage event (RFC3339 format)\n                params:\n                  type: object\n                  additionalProperties: true\n                  description: Custom parameters for the usage event\n              required:\n                - transaction_id\n                - params\n      responses:\n        \"200\":\n          description: Usage tracked successfully\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  message:\n                    type: string\n                    description: Success message\n        \"400\":\n          description: Bad request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized or invalid token\n        \"404\":\n          description: Customer not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /customers/storage:\n    post:\n      summary: Store a resource for a customer\n      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.\n      operationId: createCustomerStorageResource\n      x-excluded: true\n      tags:\n        - API Nodes\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                file_name:\n                  type: string\n                  description: The desired name of the file (e.g., 'profile.jpg')\n                content_type:\n                  type: string\n                  description: The content type of the file (e.g., 'image/png')\n                file_hash:\n                  type: string\n                  description: The hash of the file. If provided, an existing file with the same hash may be returned.\n              required:\n                - file_name\n      responses:\n        \"200\":\n          description: Signed URL generated successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/CustomerStorageResourceResponse\"\n        \"400\":\n          description: Bad request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /customers/{customer_id}/events:\n    get:\n      summary: Get events related to customer\n      operationId: GetCustomerEventsById\n      x-excluded: true\n      tags:\n        - API Nodes\n      security:\n        - BearerAuth: []\n      parameters:\n        - in: path\n          name: customer_id\n          required: true\n          schema:\n            type: string\n        - in: query\n          name: page\n          description: Page number of the nodes list\n          required: false\n          schema:\n            type: integer\n            default: 1\n        - in: query\n          name: limit\n          description: Number of nodes to return per page\n          required: false\n          schema:\n            type: integer\n            default: 10\n        - in: query\n          name: filter\n          description: Event type to filter\n          required: false\n          schema:\n            type: string\n        - in: query\n          name: start_date\n          description: Start date for filtering events (RFC3339 format, e.g., 2025-01-01T00:00:00Z)\n          required: false\n          schema:\n            type: string\n            format: date-time\n        - in: query\n          name: end_date\n          description: End date for filtering events (RFC3339 format, e.g., 2025-01-31T23:59:59Z)\n          required: false\n          schema:\n            type: string\n            format: date-time\n      responses:\n        \"200\":\n          description: A paginated list of nodes\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  total:\n                    type: integer\n                    description: Total number of events available\n                  events:\n                    type: array\n                    items:\n                      $ref: \"#/components/schemas/AuditLog\"\n                  page:\n                    type: integer\n                    description: Current page number\n                  limit:\n                    type: integer\n                    description: Maximum number of nodes per page\n                  totalPages:\n                    type: integer\n                    description: Total number of pages available\n        \"400\":\n          description: Invalid input, object invalid\n        \"404\":\n          description: Not found\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /customers/events:\n    get:\n      summary: Get events related to customer\n      operationId: GetCustomerEvents\n      x-excluded: true\n      tags:\n        - API Nodes\n      security:\n        - BearerAuth: []\n      parameters:\n        - in: query\n          name: page\n          description: Page number of the nodes list\n          required: false\n          schema:\n            type: integer\n            default: 1\n        - in: query\n          name: limit\n          description: Number of nodes to return per page\n          required: false\n          schema:\n            type: integer\n            default: 10\n        - in: query\n          name: filter\n          description: Event type to filter\n          required: false\n          schema:\n            type: string\n        - in: query\n          name: start_date\n          description: Start date for filtering events (RFC3339 format, e.g., 2025-01-01T00:00:00Z)\n          required: false\n          schema:\n            type: string\n            format: date-time\n        - in: query\n          name: end_date\n          description: End date for filtering events (RFC3339 format, e.g., 2025-01-31T23:59:59Z)\n          required: false\n          schema:\n            type: string\n            format: date-time\n      responses:\n        \"200\":\n          description: A paginated list of nodes\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  total:\n                    type: integer\n                    description: Total number of events available\n                  events:\n                    type: array\n                    items:\n                      $ref: \"#/components/schemas/AuditLog\"\n                  page:\n                    type: integer\n                    description: Current page number\n                  limit:\n                    type: integer\n                    description: Maximum number of nodes per page\n                  totalPages:\n                    type: integer\n                    description: Total number of pages available\n        \"400\":\n          description: Invalid input, object invalid\n        \"404\":\n          description: Not found\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /upload-artifact:\n    post:\n      summary: Receive artifacts (output files) from the ComfyUI GitHub Action\n      description: Receive artifacts (output files) from the ComfyUI GitHub Action\n      x-excluded: true\n      tags:\n        - ComfyUI CI\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                repo:\n                  type: string\n                  description: Repository name\n                job_id:\n                  type: string\n                  description: Unique identifier for the job\n                run_id:\n                  type: string\n                  description: Unique identifier for the run\n                os:\n                  type: string\n                  description: Operating system used in the run\n                cuda_version:\n                  type: string\n                  description: Cuda version.\n                bucket_name:\n                  type: string\n                  description: The name of the bucket where the output files are stored\n                output_files_gcs_paths:\n                  type: string\n                  description: A comma separated string that contains GCS path(s) to output files. eg. gs://bucket-name/output, gs://bucket-name/output2\n                comfy_logs_gcs_path:\n                  type: string\n                  description: The path to ComfyUI logs. eg. gs://bucket-name/logs\n                comfy_run_flags:\n                  type: string\n                  description: The flags used in the comfy run\n                commit_hash:\n                  type: string\n                commit_time:\n                  type: string\n                  description: The time of the commit in the format of \"YYYY-MM-DDTHH:MM:SSZ\" (2016-10-10T00:00:00Z)\n                commit_message:\n                  type: string\n                  description: The commit message\n                workflow_name:\n                  type: string\n                  description: The name of the workflow\n                branch_name:\n                  type: string\n                start_time:\n                  type: integer\n                  format: int64\n                  description: The start time of the job as a Unix timestamp.\n                end_time:\n                  type: integer\n                  format: int64\n                  description: The end time of the job as a Unix timestamp.\n                avg_vram:\n                  type: integer\n                  description: The average amount of VRAM used in the run.\n                peak_vram:\n                  type: integer\n                  description: The peak amount of VRAM used in the run.\n                pr_number:\n                  type: string\n                  description: The pull request number\n                author:\n                  type: string\n                  description: The author of the commit\n                job_trigger_user:\n                  type: string\n                  description: The user who triggered the job\n                python_version:\n                  type: string\n                  description: The python version used in the run\n                pytorch_version:\n                  type: string\n                  description: The pytorch version used in the run\n                machine_stats:\n                  $ref: \"#/components/schemas/MachineStats\"\n                status:\n                  $ref: \"#/components/schemas/WorkflowRunStatus\"\n              required:\n                - repo\n                - job_id\n                - run_id\n                - os\n                - commit_hash\n                - commit_time\n                - commit_message\n                - branch_name\n                - workflow_name\n                - start_time\n                - end_time\n                - pr_number\n                - python_version\n                - job_trigger_user\n                - author\n                - status\n\n      responses:\n        \"200\":\n          description: Successfully received the artifact details\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  message:\n                    type: string\n        \"400\":\n          description: Invalid request\n        \"500\":\n          description: Internal server error\n  /gitcommit:\n    get:\n      summary: Retrieve CI data for a given commit\n      description: Returns all runs, jobs, job results, and storage files associated with a given commit.\n      x-excluded: true\n      tags:\n        - ComfyUI CI\n      parameters:\n        - in: query\n          name: commitId\n          required: false\n          schema:\n            type: string\n          description: The ID of the commit to fetch data for.\n        - in: query\n          name: operatingSystem\n          required: false\n          schema:\n            type: string\n          description: The operating system to filter the CI data by.\n        - in: query\n          name: workflowName\n          required: false\n          schema:\n            type: string\n          description: The name of the workflow to filter the CI data by.\n        - in: query\n          name: branch\n          required: false\n          schema:\n            type: string\n          description: The branch of the gitcommit to filter the CI data by.\n        - in: query\n          name: page\n          required: false\n          schema:\n            type: integer\n            default: 1\n          description: The page number to retrieve.\n        - in: query\n          name: pageSize\n          required: false\n          schema:\n            type: integer\n            default: 10\n          description: The number of items to include per page.\n        - in: query\n          name: repoName\n          required: false\n          schema:\n            type: string\n            default: comfyanonymous/ComfyUI\n          description: The repo to filter by.\n      responses:\n        \"200\":\n          description: An object containing runs, jobs, job results, and storage files\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  jobResults:\n                    type: array\n                    items:\n                      $ref: \"#/components/schemas/ActionJobResult\"\n                  totalNumberOfPages:\n                    type: integer\n        \"404\":\n          description: Commit not found\n        \"500\":\n          description: Internal server error\n  /gitcommitsummary:\n    get:\n      summary: Retrieve a summary of git commits\n      description: Returns a summary of git commits, including status, start time, and end time.\n      x-excluded: true\n      tags:\n        - ComfyUI CI\n      parameters:\n        - in: query\n          name: repoName\n          required: false\n          schema:\n            type: string\n            default: comfyanonymous/ComfyUI\n          description: The repository name to filter the git commits by.\n        - in: query\n          name: branchName\n          required: false\n          schema:\n            type: string\n          description: The branch name to filter the git commits by.\n        - in: query\n          name: page\n          required: false\n          schema:\n            type: integer\n            default: 1\n          description: The page number to retrieve.\n        - in: query\n          name: pageSize\n          required: false\n          schema:\n            type: integer\n            default: 10\n          description: The number of items to include per page.\n      responses:\n        \"200\":\n          description: Successfully retrieved git commit summaries\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  commitSummaries:\n                    type: array\n                    items:\n                      $ref: \"#/components/schemas/GitCommitSummary\"\n                  totalNumberOfPages:\n                    type: integer\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  message:\n                    type: string\n  /workflowresult/{workflowResultId}:\n    get:\n      summary: Retrieve a specific commit by ID\n      operationId: getWorkflowResult\n      x-excluded: true\n      tags:\n        - ComfyUI CI\n      parameters:\n        - in: path\n          name: workflowResultId\n          required: true\n          schema:\n            type: string\n      responses:\n        \"200\":\n          description: Commit details\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ActionJobResult\"\n        \"404\":\n          description: Commit not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /branch:\n    get:\n      summary: Retrieve all distinct branches for a given repo\n      description: Returns all branches for a given repo.\n      x-excluded: true\n      tags:\n        - ComfyUI CI\n      parameters:\n        - in: query\n          name: repo_name\n          required: true\n          schema:\n            type: string\n            default: comfyanonymous/ComfyUI\n          description: The repo to filter by.\n      responses:\n        \"200\":\n          description: An array of branches\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  branches:\n                    type: array\n                    items:\n                      type: string\n        \"404\":\n          description: Repo not found\n        \"500\":\n          description: Internal server error\n  /users/publishers/:\n    get:\n      summary: Retrieve all publishers for a given user\n      operationId: listPublishersForUser\n      tags:\n        - Registry\n      responses:\n        \"200\":\n          description: A list of publishers\n          content:\n            application/json:\n              schema:\n                type: array\n                items:\n                  $ref: \"#/components/schemas/Publisher\"\n        \"400\":\n          description: Bad request, invalid input data\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /publishers/{publisherId}/permissions:\n    get:\n      summary: Retrieve permissions the user has for a given publisher\n      operationId: getPermissionOnPublisher\n      tags:\n        - Registry\n      parameters:\n        - in: path\n          name: publisherId\n          required: true\n          schema:\n            type: string\n      responses:\n        \"200\":\n          description: A list of permissions\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  canEdit:\n                    type: boolean\n        \"400\":\n          description: Bad request, invalid input data\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /publishers/validate:\n    get:\n      summary: Validate if a publisher username is available\n      description: Checks if the publisher username is already taken.\n      operationId: validatePublisher\n      tags:\n        - Registry\n      parameters:\n        - in: query\n          name: username\n          schema:\n            type: string\n          description: The publisher username to validate.\n          required: true\n      responses:\n        \"200\":\n          description: Username validation result\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  isAvailable:\n                    type: boolean\n                    description: True if the username is available, false otherwise.\n        \"400\":\n          description: Invalid input, such as missing username in the query.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /publishers:\n    post:\n      summary: Create a new publisher\n      operationId: createPublisher\n      security:\n        - BearerAuth: []\n      tags:\n        - Registry\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/Publisher\"\n      responses:\n        \"201\":\n          description: Publisher created successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Publisher\"\n        \"400\":\n          description: Bad request, invalid input data\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n    get:\n      summary: Retrieve all publishers\n      operationId: listPublishers\n      tags:\n        - Registry\n      responses:\n        \"200\":\n          description: A list of publishers\n          content:\n            application/json:\n              schema:\n                type: array\n                items:\n                  $ref: \"#/components/schemas/Publisher\"\n        \"400\":\n          description: Bad request, invalid input data\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /publishers/{publisherId}:\n    get:\n      summary: Retrieve a publisher by ID\n      operationId: getPublisher\n      tags:\n        - Registry\n      parameters:\n        - in: path\n          name: publisherId\n          required: true\n          schema:\n            type: string\n      responses:\n        \"200\":\n          description: Publisher retrieved successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Publisher\"\n        \"404\":\n          description: Publisher not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n    put:\n      summary: Update a publisher\n      operationId: updatePublisher\n      security:\n        - BearerAuth: []\n      tags:\n        - Registry\n      parameters:\n        - in: path\n          name: publisherId\n          required: true\n          schema:\n            type: string\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/Publisher\"\n      responses:\n        \"200\":\n          description: Publisher updated successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Publisher\"\n        \"400\":\n          description: Bad request, invalid input data\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"404\":\n          description: Publisher not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n    delete:\n      summary: Delete a publisher\n      operationId: deletePublisher\n      security:\n        - BearerAuth: []\n      tags:\n        - Registry\n      parameters:\n        - in: path\n          name: publisherId\n          required: true\n          schema:\n            type: string\n      responses:\n        \"204\":\n          description: Publisher deleted successfully\n        \"404\":\n          description: Publisher not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /publishers/{publisherId}/ban:\n    post:\n      summary: Ban a publisher\n      operationId: BanPublisher\n      tags:\n        - Registry\n      x-excluded: true\n      parameters:\n        - in: path\n          name: publisherId\n          required: true\n          schema:\n            type: string\n      responses:\n        \"204\":\n          description: Publisher Banned Successfully\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: Publisher not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /publishers/{publisherId}/nodes/{nodeId}/claim-my-node:\n    post:\n      summary: Claim nodeId into publisherId for the authenticated publisher\n      description: |\n        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.\n      operationId: claimMyNode\n      tags:\n        - Registry\n      security:\n        - BearerAuth: []\n      parameters:\n        - in: path\n          name: publisherId\n          required: true\n          schema:\n            type: string\n        - in: path\n          name: nodeId\n          required: true\n          schema:\n            type: string\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/ClaimMyNodeRequest\"\n      responses:\n        \"204\":\n          description: Node claimed successfully\n        \"400\":\n          description: Bad request, invalid input data\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: |\n            Forbidden - various authorization and permission issues\n            Includes:\n            - The authenticated user does not have permission to claim the node\n            - The node is already claimed by another publisher\n            - The GH_TOKEN is invalid\n            - The repository is not owned by the authenticated GitHub user\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"429\":\n          description: Too many requests - GitHub API rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"503\":\n          description: Service unavailable - GitHub API is currently unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /publishers/{publisherId}/nodes/v2:\n    get:\n      summary: Retrieve all nodes\n      operationId: listNodesForPublisherV2\n      security:\n        - BearerAuth: []\n      tags:\n        - Registry\n      parameters:\n        - in: path\n          name: publisherId\n          required: true\n          schema:\n            type: string\n        - in: query\n          name: include_banned\n          description: Number of nodes to return per page\n          required: false\n          schema:\n            type: boolean\n        - in: query\n          name: page\n          description: Page number of the nodes list\n          required: false\n          schema:\n            type: integer\n            default: 1\n        - in: query\n          name: limit\n          description: Number of nodes to return per page\n          required: false\n          schema:\n            type: integer\n            default: 10\n      responses:\n        \"200\":\n          description: List of all nodes\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  total:\n                    type: integer\n                    description: Total number of nodes available\n                  nodes:\n                    type: array\n                    items:\n                      $ref: \"#/components/schemas/Node\"\n                  page:\n                    type: integer\n                    description: Current page number\n                  limit:\n                    type: integer\n                    description: Maximum number of nodes per page\n                  totalPages:\n                    type: integer\n                    description: Total number of pages available\n        \"400\":\n          description: Bad request, invalid input data.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /publishers/{publisherId}/nodes:\n    post:\n      summary: Create a new custom node\n      operationId: createNode\n      tags:\n        - Registry\n      security:\n        - BearerAuth: []\n      parameters:\n        - in: path\n          name: publisherId\n          required: true\n          schema:\n            type: string\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/Node\"\n      responses:\n        \"201\":\n          description: Node created successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Node\"\n        \"400\":\n          description: Bad request, invalid input data.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n    get:\n      summary: Retrieve all nodes\n      operationId: listNodesForPublisher\n      security:\n        - BearerAuth: []\n      tags:\n        - Registry\n      parameters:\n        - in: path\n          name: publisherId\n          required: true\n          schema:\n            type: string\n        - in: query\n          name: include_banned\n          description: Number of nodes to return per page\n          required: false\n          schema:\n            type: boolean\n      responses:\n        \"200\":\n          description: List of all nodes\n          content:\n            application/json:\n              schema:\n                type: array\n                items:\n                  $ref: \"#/components/schemas/Node\"\n        \"400\":\n          description: Bad request, invalid input data.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /publishers/{publisherId}/nodes/{nodeId}:\n    put:\n      summary: Update a specific node\n      operationId: updateNode\n      tags:\n        - Registry\n      security:\n        - BearerAuth: []\n      parameters:\n        - in: path\n          name: publisherId\n          required: true\n          schema:\n            type: string\n        - in: path\n          name: nodeId\n          required: true\n          schema:\n            type: string\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/Node\"\n      responses:\n        \"200\":\n          description: Node updated successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Node\"\n        \"400\":\n          description: Bad request, invalid input data\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: Node not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n    delete:\n      summary: Delete a specific node\n      operationId: deleteNode\n      tags:\n        - Registry\n      security:\n        - BearerAuth: []\n      parameters:\n        - in: path\n          name: publisherId\n          required: true\n          schema:\n            type: string\n        - in: path\n          name: nodeId\n          required: true\n          schema:\n            type: string\n      responses:\n        \"204\":\n          description: Node deleted successfully\n        \"404\":\n          description: Node not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"403\":\n          description: Forbidden\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /publishers/{publisherId}/nodes/{nodeId}/permissions:\n    get:\n      summary: Retrieve permissions the user has for a given publisher\n      operationId: getPermissionOnPublisherNodes\n      tags:\n        - Registry\n      parameters:\n        - in: path\n          name: publisherId\n          required: true\n          schema:\n            type: string\n        - in: path\n          name: nodeId\n          required: true\n          schema:\n            type: string\n      responses:\n        \"200\":\n          description: A list of permissions\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  canEdit:\n                    type: boolean\n        \"400\":\n          description: Bad request, invalid input data\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /publishers/{publisherId}/nodes/{nodeId}/versions:\n    post:\n      summary: Publish a new version of a node\n      operationId: publishNodeVersion\n      tags:\n        - Registry\n      security:\n        - BearerAuth: []\n      parameters:\n        - in: path\n          name: publisherId\n          required: true\n          schema:\n            type: string\n        - in: path\n          name: nodeId\n          required: true\n          schema:\n            type: string\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                personal_access_token:\n                  type: string\n                node_version:\n                  $ref: \"#/components/schemas/NodeVersion\"\n                node:\n                  $ref: \"#/components/schemas/Node\"\n              required:\n                - node\n                - node_version\n                - personal_access_token\n      responses:\n        \"201\":\n          description: New version published successfully\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  signedUrl:\n                    type: string\n                    description: The signed URL to upload the node version token.\n                  node_version:\n                    $ref: \"#/components/schemas/NodeVersion\"\n        \"400\":\n          description: Bad request, invalid input data.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"403\":\n          description: Forbidden\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /publishers/{publisherId}/nodes/{nodeId}/versions/{versionId}:\n    delete:\n      summary: Unpublish (delete) a specific version of a node\n      operationId: deleteNodeVersion\n      tags:\n        - Registry\n      security:\n        - BearerAuth: []\n      parameters:\n        - in: path\n          name: publisherId\n          required: true\n          schema:\n            type: string\n        - in: path\n          name: nodeId\n          required: true\n          schema:\n            type: string\n        - in: path\n          name: versionId\n          required: true\n          schema:\n            type: string\n      responses:\n        \"204\":\n          description: Version unpublished (deleted) successfully\n        \"403\":\n          description: Version does not belong to the publisher\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: Version not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"500\":\n          description: Version not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n    put:\n      summary: Update changelog and deprecation status of a node version\n      operationId: updateNodeVersion\n      description: Update only the changelog and deprecated status of a specific version of a node.\n      tags:\n        - Registry\n      security:\n        - BearerAuth: []\n      parameters:\n        - in: path\n          name: publisherId\n          required: true\n          schema:\n            type: string\n        - in: path\n          name: nodeId\n          required: true\n          schema:\n            type: string\n        - in: path\n          name: versionId\n          required: true\n          schema:\n            type: string\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/NodeVersionUpdateRequest\"\n      responses:\n        \"200\":\n          description: Version updated successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/NodeVersion\"\n        \"400\":\n          description: Bad request, invalid input data.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: Version not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /publishers/{publisherId}/nodes/{nodeId}/ban:\n    post:\n      summary: Ban a publisher's Node\n      operationId: BanPublisherNode\n      tags:\n        - Registry\n      x-excluded: true\n      parameters:\n        - in: path\n          name: publisherId\n          required: true\n          schema:\n            type: string\n        - in: path\n          name: nodeId\n          required: true\n          schema:\n            type: string\n      responses:\n        \"204\":\n          description: Node Banned Successfully\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: Publisher or Node not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /publishers/{publisherId}/tokens:\n    post:\n      summary: Create a new personal access token\n      operationId: createPersonalAccessToken\n      security:\n        - BearerAuth: []\n      tags:\n        - Registry\n      parameters:\n        - in: path\n          name: publisherId\n          required: true\n          schema:\n            type: string\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/PersonalAccessToken\"\n      responses:\n        \"201\":\n          description: Token created successfully\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  token:\n                    type: string\n                    description: The newly created personal access token.\n        \"400\":\n          description: Bad request, invalid input data.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"403\":\n          description: Forbidden\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n    get:\n      summary: Retrieve all personal access tokens for a publisher\n      operationId: listPersonalAccessTokens\n      security:\n        - BearerAuth: []\n      tags:\n        - Registry\n      x-excluded: true\n      parameters:\n        - in: path\n          name: publisherId\n          required: true\n          schema:\n            type: string\n      responses:\n        \"200\":\n          description: List of all personal access tokens\n          content:\n            application/json:\n              schema:\n                type: array\n                items:\n                  $ref: \"#/components/schemas/PersonalAccessToken\"\n        \"403\":\n          description: Forbidden\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: No tokens found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /publishers/{publisherId}/tokens/{tokenId}:\n    delete:\n      summary: Delete a specific personal access token\n      operationId: deletePersonalAccessToken\n      security:\n        - BearerAuth: []\n      tags:\n        - Registry\n      parameters:\n        - in: path\n          name: publisherId\n          required: true\n          schema:\n            type: string\n        - in: path\n          name: tokenId\n          required: true\n          schema:\n            type: string\n      responses:\n        \"204\":\n          description: Token deleted successfully\n        \"403\":\n          description: Forbidden\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: Token not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /nodes/search:\n    get:\n      summary: Retrieves a list of nodes\n      description: Returns a paginated list of nodes across all publishers.\n      operationId: searchNodes\n      tags:\n        - Registry\n      parameters:\n        - in: query\n          name: page\n          description: Page number of the nodes list\n          required: false\n          schema:\n            type: integer\n            default: 1\n        - in: query\n          name: limit\n          description: Number of nodes to return per page\n          required: false\n          schema:\n            type: integer\n            default: 10\n        - in: query\n          name: search\n          description: Keyword to search the nodes\n          required: false\n          schema:\n            type: string\n        - in: query\n          name: repository_url_search\n          description: Keyword to search the nodes by repository URL\n          required: false\n          schema:\n            type: string\n        - in: query\n          name: comfy_node_search\n          description: Keyword to search the nodes by comfy node name\n          required: false\n          schema:\n            type: string\n        - in: query\n          name: supported_os\n          description: Filter nodes by supported operating systems\n          required: false\n          schema:\n            type: string\n          examples:\n            osIndependent:\n              value: \"OS Independent\"\n            windows:\n              value: \"Microsoft :: Windows\"\n            windows10:\n              value: \"Microsoft :: Windows :: Windows 10\"\n            linux:\n              value: \"POSIX :: Linux\"\n            ubuntu:\n              value: \"POSIX :: Linux :: Ubuntu\"\n            macos:\n              value: \"MacOS\"\n            macosx:\n              value: \"MacOS :: MacOS X\"\n        - in: query\n          name: supported_accelerator\n          description: Filter nodes by supported accelerator\n          required: false\n          schema:\n            type: string\n        - in: query\n          name: include_banned\n          description: Number of nodes to return per page\n          required: false\n          schema:\n            type: boolean\n      responses:\n        \"200\":\n          description: A paginated list of nodes\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  total:\n                    type: integer\n                    description: Total number of nodes available\n                  nodes:\n                    type: array\n                    items:\n                      $ref: \"#/components/schemas/Node\"\n                  page:\n                    type: integer\n                    description: Current page number\n                  limit:\n                    type: integer\n                    description: Maximum number of nodes per page\n                  totalPages:\n                    type: integer\n                    description: Total number of pages available\n        \"400\":\n          description: Invalid input, object invalid\n        \"404\":\n          description: Not found\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /nodes/reindex:\n    post:\n      summary: Reindex all nodes for searching.\n      operationId: reindexNodes\n      tags:\n        - Registry\n      x-excluded: true\n      parameters:\n        - in: query\n          name: max_batch\n          description: Maximum number of nodes to send to algolia at a time\n          required: false\n          schema:\n            type: integer\n      responses:\n        \"200\":\n          description: Reindex completed successfully.\n        \"400\":\n          description: Bad request.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /nodes/update-github-stars:\n    post:\n      summary: Update GitHub stars for nodes\n      operationId: updateGithubStars\n      tags:\n        - Registry\n      x-excluded: true\n      parameters:\n        - in: query\n          name: max_batch\n          schema:\n            type: integer\n            default: 100\n          description: Maximum number of nodes to update in one batch\n      responses:\n        \"200\":\n          description: Update GithubStars request triggered successfully\n        \"400\":\n          description: Bad request.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /nodes:\n    get:\n      summary: Retrieves a list of nodes\n      description: Returns a paginated list of nodes across all publishers.\n      operationId: listAllNodes\n      tags:\n        - Registry\n      parameters:\n        - in: query\n          name: page\n          description: Page number of the nodes list\n          required: false\n          schema:\n            type: integer\n            default: 1\n        - in: query\n          name: limit\n          description: Number of nodes to return per page\n          required: false\n          schema:\n            type: integer\n            default: 10\n        - in: query\n          name: supported_os\n          description: Filter nodes by supported operating systems\n          required: false\n          schema:\n            type: string\n          examples:\n            osIndependent:\n              value: \"OS Independent\"\n            windows:\n              value: \"Microsoft :: Windows\"\n            windows10:\n              value: \"Microsoft :: Windows :: Windows 10\"\n            linux:\n              value: \"POSIX :: Linux\"\n            ubuntu:\n              value: \"POSIX :: Linux :: Ubuntu\"\n            macos:\n              value: \"MacOS\"\n            macosx:\n              value: \"MacOS :: MacOS X\"\n        - in: query\n          name: supported_accelerator\n          description: Filter nodes by supported accelerator\n          required: false\n          schema:\n            type: string\n        - in: query\n          name: include_banned\n          description: Number of nodes to return per page\n          required: false\n          schema:\n            type: boolean\n        - in: query\n          name: timestamp\n          description: Retrieve nodes created or updated after this timestamp (ISO 8601 format)\n          required: false\n          schema:\n            type: string\n            format: date-time\n        - in: query\n          name: latest\n          description: Whether to fetch fresh result from database or use cached one if false\n          required: false\n          schema:\n            type: boolean\n        - in: query\n          name: sort\n          description: Database column to use as ascending ordering. Add `;desc` as suffix on each column for descending sort\n          required: false\n          schema:\n            type: array\n            items:\n              type: string\n        - in: query\n          name: node_id\n          description: node_id to use as filter\n          required: false\n          schema:\n            type: array\n            items:\n              type: string\n        - in: query\n          name: comfyui_version\n          description: Comfy UI version\n          required: false\n          schema:\n            type: string\n        - in: query\n          name: form_factor\n          description: The platform requesting the nodes\n          required: false\n          schema:\n            type: string\n      responses:\n        \"200\":\n          description: A paginated list of nodes\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  total:\n                    type: integer\n                    description: Total number of nodes available\n                  nodes:\n                    type: array\n                    items:\n                      $ref: \"#/components/schemas/Node\"\n                  page:\n                    type: integer\n                    description: Current page number\n                  limit:\n                    type: integer\n                    description: Maximum number of nodes per page\n                  totalPages:\n                    type: integer\n                    description: Total number of pages available\n        \"400\":\n          description: Invalid input, object invalid\n        \"404\":\n          description: Not found\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /comfy-nodes/{comfyNodeName}/node:\n    get:\n      summary: Retrieve a node by ComfyUI node name\n      description: Returns the node that contains a ComfyUI node with the specified name\n      operationId: getNodeByComfyNodeName\n      tags:\n        - Registry\n      parameters:\n        - in: path\n          name: comfyNodeName\n          required: true\n          description: The name of the ComfyUI node\n          schema:\n            type: string\n      responses:\n        \"200\":\n          description: Node details\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Node\"\n        \"404\":\n          description: No node found containing the specified ComfyUI node name\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /nodes/{nodeId}:\n    get:\n      summary: Retrieve a specific node by ID\n      description: Returns the details of a specific node.\n      operationId: getNode\n      tags:\n        - Registry\n      parameters:\n        - in: path\n          name: nodeId\n          required: true\n          schema:\n            type: string\n        - in: query\n          name: include_translations\n          description: Whether to include the translation or not\n          schema:\n            type: boolean\n      responses:\n        \"200\":\n          description: Node details\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Node\"\n        \"302\":\n          description: Redirect to node with normalized name match\n          headers:\n            Location:\n              description: URL of the node with the correct ID\n              schema:\n                type: string\n        \"403\":\n          description: Forbidden\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: Node not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /nodes/{nodeId}/reviews:\n    post:\n      summary: Add review to a specific version of a node\n      operationId: postNodeReview\n      tags:\n        - Registry\n      parameters:\n        - in: path\n          name: nodeId\n          required: true\n          schema:\n            type: string\n        - in: query\n          name: star\n          description: number of star given to the node version\n          required: true\n          schema:\n            type: integer\n      responses:\n        \"200\":\n          description: Detailed information about a specific node\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Node\"\n        \"400\":\n          description: Bad Request\n        \"404\":\n          description: Node version not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /nodes/{nodeId}/install:\n    get:\n      summary: Returns a node version to be installed.\n      description: Retrieves the node data for installation, either the latest or a specific version.\n      operationId: installNode\n      tags:\n        - Registry\n      parameters:\n        - in: path\n          name: nodeId\n          required: true\n          description: The unique identifier of the node.\n          schema:\n            type: string\n        - in: query\n          name: version\n          required: false\n          description: Specific version of the node to retrieve. If omitted, the latest version is returned.\n          schema:\n            type: string\n            pattern: '^\\d+\\.\\d+\\.\\d+$'\n      responses:\n        \"200\":\n          description: Node data returned successfully.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/NodeVersion\"\n        \"400\":\n          description: Invalid input, such as a bad version format.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"403\":\n          description: Forbidden\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: Node not found.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /nodes/{nodeId}/translations:\n    post:\n      summary: Create Node Translations\n      operationId: CreateNodeTranslations\n      tags:\n        - Registry\n      parameters:\n        - in: path\n          name: nodeId\n          required: true\n          description: The unique identifier of the node.\n          schema:\n            type: string\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                data:\n                  type: object\n                  additionalProperties:\n                    type: object\n                    additionalProperties: true\n      responses:\n        \"201\":\n          description: Detailed information about a specific node\n        \"400\":\n          description: Bad Request\n        \"404\":\n          description: Node version not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /nodes/{nodeId}/versions:\n    get:\n      summary: List all versions of a node\n      operationId: listNodeVersions\n      tags:\n        - Registry\n      parameters:\n        - in: path\n          name: nodeId\n          required: true\n          schema:\n            type: string\n        - in: query\n          name: statuses\n          required: false\n          schema:\n            type: array\n            items:\n              $ref: \"#/components/schemas/NodeVersionStatus\"\n        # parameter to include status_reason, default to false\n        - in: query\n          name: include_status_reason\n          required: false\n          schema:\n            type: boolean\n            default: false\n      responses:\n        \"200\":\n          description: List of all node versions\n          content:\n            application/json:\n              schema:\n                type: array\n                items:\n                  $ref: \"#/components/schemas/NodeVersion\"\n        \"403\":\n          description: Node banned\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: Node not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /nodes/{nodeId}/versions/{versionId}:\n    get:\n      summary: Retrieve a specific version of a node\n      operationId: getNodeVersion\n      tags:\n        - Registry\n      parameters:\n        - in: path\n          name: nodeId\n          required: true\n          schema:\n            type: string\n        - in: path\n          name: versionId\n          description: The version of the node. (Not a UUID).\n          required: true\n          schema:\n            type: string\n      responses:\n        \"200\":\n          description: Detailed information about a specific node version\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/NodeVersion\"\n        \"404\":\n          description: Node version not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /bulk/nodes/versions:\n    post:\n      summary: Retrieve multiple node versions in a single request\n      operationId: getBulkNodeVersions\n      tags:\n        - Registry\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/BulkNodeVersionsRequest\"\n      responses:\n        \"200\":\n          description: Successfully retrieved node versions\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BulkNodeVersionsResponse\"\n        \"400\":\n          description: Bad request, invalid input\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /versions:\n    get:\n      summary: List all node versions given some filters.\n      operationId: listAllNodeVersions\n      tags:\n        - Registry\n      parameters:\n        - in: query\n          name: nodeId\n          required: false\n          schema:\n            type: string\n        - in: query\n          name: statuses\n          required: false\n          style: form\n          explode: true\n          schema:\n            type: array\n            items:\n              $ref: \"#/components/schemas/NodeVersionStatus\"\n        # parameter to include status_reason, default to false\n        - in: query\n          name: include_status_reason\n          required: false\n          schema:\n            type: boolean\n            default: false\n        - in: query\n          name: page\n          required: false\n          schema:\n            type: integer\n            default: 1\n          description: The page number to retrieve.\n        - in: query\n          name: pageSize\n          required: false\n          schema:\n            type: integer\n            default: 10\n          description: The number of items to include per page.\n        - in: query\n          name: status_reason\n          required: false\n          schema:\n            type: string\n          description: search for status_reason, case insensitive\n      responses:\n        \"200\":\n          description: List of all node versions\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  total:\n                    type: integer\n                    description: Total number of node versions available\n                  versions:\n                    type: array\n                    items:\n                      $ref: \"#/components/schemas/NodeVersion\"\n                  page:\n                    type: integer\n                    description: Current page number\n                  pageSize:\n                    type: integer\n                    description: Maximum number of node versions per page. Maximum is 100.\n                  totalPages:\n                    type: integer\n                    description: Total number of pages available\n        \"400\":\n          description: Invalid input, object invalid\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"403\":\n          description: Node banned\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /admin/nodes:\n    post:\n      summary: Create a new custom node using admin priviledge\n      operationId: adminCreateNode\n      x-excluded: true\n      tags:\n        - Registry\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/Node\"\n      responses:\n        \"201\":\n          description: Node created successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Node\"\n        \"400\":\n          description: Bad request, invalid input data.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"409\":\n          description: Duplicate error.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /admin/nodes/{nodeId}:\n    put:\n      summary: Admin Update Node\n      operationId: adminUpdateNode\n      description: Only admins can update a node with admin privileges.\n      x-excluded: true\n      tags:\n        - Registry\n      security:\n        - BearerAuth: []\n      parameters:\n        - in: path\n          name: nodeId\n          required: true\n          schema:\n            type: string\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/Node\"\n      responses:\n        \"200\":\n          description: Node updated successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Node\"\n        \"400\":\n          description: Bad request, invalid input data.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: Node not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /admin/nodes/{nodeId}/versions/{versionNumber}:\n    put:\n      summary: Admin Update Node Version Status\n      operationId: adminUpdateNodeVersion\n      description: Only admins can approve a node version.\n      x-excluded: true\n      tags:\n        - Registry\n      security:\n        - BearerAuth: []\n      parameters:\n        - in: path\n          name: nodeId\n          required: true\n          schema:\n            type: string\n        - in: path\n          name: versionNumber\n          required: true\n          schema:\n            type: string\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                status:\n                  $ref: \"#/components/schemas/NodeVersionStatus\"\n                status_reason:\n                  type: string\n                  description: The reason for the status change.\n                supported_comfyui_frontend_version:\n                  type: string\n                  description: Supported versions of ComfyUI frontend\n                supported_comfyui_version:\n                  type: string\n                  description: Supported versions of ComfyUI\n                supported_os:\n                  type: array\n                  items:\n                    type: string\n                  description: List of operating systems that this node supports\n                supported_accelerators:\n                  type: array\n                  items:\n                    type: string\n                  description: List of accelerators (e.g. CUDA, DirectML, ROCm) that this node supports\n\n      responses:\n        \"200\":\n          description: Version updated successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/NodeVersion\"\n        \"400\":\n          description: Bad request, invalid input data.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: Version not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /customers/admin/coupons:\n    get:\n      summary: List all coupons\n      operationId: listCoupons\n      description: Retrieves a list of all coupons from Stripe. Only admins can list coupons.\n      x-excluded: true\n      tags:\n        - Admin\n        - API Nodes\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: limit\n          in: query\n          required: false\n          schema:\n            type: integer\n            minimum: 1\n            maximum: 100\n            default: 10\n          description: Number of coupons to return\n      responses:\n        \"200\":\n          description: List of coupons retrieved successfully\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  coupons:\n                    type: array\n                    items:\n                      $ref: \"#/components/schemas/CouponResponse\"\n                  has_more:\n                    type: boolean\n                    description: Whether there are more results available\n                required:\n                  - coupons\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden - Admin access required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n    post:\n      summary: Create a new Stripe coupon\n      operationId: createCoupon\n      description: Creates a new coupon in Stripe. Only admins can create coupons.\n      x-excluded: true\n      tags:\n        - Admin\n        - API Nodes\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/CreateCouponRequest\"\n      responses:\n        \"201\":\n          description: Coupon created successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/CouponResponse\"\n        \"400\":\n          description: Bad request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden - Admin access required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /customers/admin/coupons/{coupon_id}:\n    get:\n      summary: Get a specific coupon\n      operationId: getCoupon\n      description: Retrieves details of a specific coupon from Stripe. Only admins can view coupons.\n      x-excluded: true\n      tags:\n        - Admin\n        - API Nodes\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: coupon_id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: The Stripe coupon ID\n      responses:\n        \"200\":\n          description: Coupon retrieved successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/CouponResponse\"\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden - Admin access required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: Coupon not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n    patch:\n      summary: Update a coupon\n      operationId: updateCoupon\n      description: Updates a coupon in Stripe. Only admins can update coupons.\n      x-excluded: true\n      tags:\n        - Admin\n        - API Nodes\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: coupon_id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: The Stripe coupon ID\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/UpdateCouponRequest\"\n      responses:\n        \"200\":\n          description: Coupon updated successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/CouponResponse\"\n        \"400\":\n          description: Bad request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden - Admin access required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: Coupon not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n    delete:\n      summary: Delete a coupon\n      operationId: deleteCoupon\n      description: Deletes a coupon in Stripe. Only admins can delete coupons.\n      x-excluded: true\n      tags:\n        - Admin\n        - API Nodes\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: coupon_id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: The Stripe coupon ID\n      responses:\n        \"200\":\n          description: Coupon deleted successfully\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  message:\n                    type: string\n                    description: Success message\n                  coupon_id:\n                    type: string\n                    description: The deleted coupon ID\n                required:\n                  - message\n                  - coupon_id\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden - Admin access required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: Coupon not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /customers/admin/promo-codes:\n    get:\n      summary: List all promotional codes\n      operationId: listPromoCodes\n      description: Retrieves a list of all promotional codes from Stripe. Only admins can list promo codes.\n      x-excluded: true\n      tags:\n        - Admin\n        - API Nodes\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: active\n          in: query\n          required: false\n          schema:\n            type: boolean\n          description: Filter by active status\n        - name: limit\n          in: query\n          required: false\n          schema:\n            type: integer\n            minimum: 1\n            maximum: 100\n            default: 10\n          description: Number of promo codes to return\n      responses:\n        \"200\":\n          description: List of promo codes retrieved successfully\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  promo_codes:\n                    type: array\n                    items:\n                      $ref: \"#/components/schemas/PromoCodeResponse\"\n                  has_more:\n                    type: boolean\n                    description: Whether there are more results available\n                required:\n                  - promo_codes\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden - Admin access required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n    post:\n      summary: Generate a new Stripe promotional code\n      operationId: createPromoCode\n      description: Creates a new unique promotional code in Stripe for the specified coupon. Only admins can generate promo codes.\n      x-excluded: true\n      tags:\n        - Admin\n        - API Nodes\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/CreatePromoCodeRequest\"\n      responses:\n        \"201\":\n          description: Promo code created successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/PromoCodeResponse\"\n        \"400\":\n          description: Bad request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden - Admin access required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /customers/admin/promo-codes/{promo_code_id}:\n    get:\n      summary: Get a specific promotional code\n      operationId: getPromoCode\n      description: Retrieves details of a specific promotional code from Stripe. Only admins can view promo codes.\n      x-excluded: true\n      tags:\n        - Admin\n        - API Nodes\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: promo_code_id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: The Stripe promotion code ID\n      responses:\n        \"200\":\n          description: Promo code retrieved successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/PromoCodeResponse\"\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden - Admin access required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: Promo code not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n    patch:\n      summary: Update a promotional code\n      operationId: updatePromoCode\n      description: Updates a promotional code in Stripe. Only admins can update promo codes.\n      x-excluded: true\n      tags:\n        - Admin\n        - API Nodes\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: promo_code_id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: The Stripe promotion code ID\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/UpdatePromoCodeRequest\"\n      responses:\n        \"200\":\n          description: Promo code updated successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/PromoCodeResponse\"\n        \"400\":\n          description: Bad request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden - Admin access required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: Promo code not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n    delete:\n      summary: Deactivate a promotional code\n      operationId: deletePromoCode\n      description: Deactivates a promotional code in Stripe. Only admins can deactivate promo codes.\n      x-excluded: true\n      tags:\n        - Admin\n        - API Nodes\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: promo_code_id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: The Stripe promotion code ID\n      responses:\n        \"200\":\n          description: Promo code deactivated successfully\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  message:\n                    type: string\n                    description: Success message\n                  promo_code_id:\n                    type: string\n                    description: The deactivated promo code ID\n                required:\n                  - message\n                  - promo_code_id\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden - Admin access required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: Promo code not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n\n  /releases:\n    post:\n      summary: Process Github release webhook\n      operationId: processReleaseWebhook\n      description: Webhook endpoint to process Github release events and generate release notes\n      tags:\n        - Releases\n      x-excluded: true\n      parameters:\n        - name: X-GitHub-Event\n          in: header\n          required: true\n          schema:\n            type: string\n            enum: [release]\n          description: The name of the event that triggered the delivery\n        - name: X-GitHub-Delivery\n          in: header\n          required: true\n          schema:\n            type: string\n            format: uuid\n          description: A globally unique identifier (GUID) to identify the event\n        - name: X-GitHub-Hook-ID\n          in: header\n          required: true\n          schema:\n            type: string\n          description: The unique identifier of the webhook\n        - name: X-Hub-Signature-256\n          in: header\n          required: false\n          schema:\n            type: string\n          description: HMAC hex digest of the request body using SHA-256 hash function\n        - name: X-GitHub-Hook-Installation-Target-Type\n          in: header\n          required: false\n          schema:\n            type: string\n          description: The type of resource where the webhook was created\n        - name: X-GitHub-Hook-Installation-Target-ID\n          in: header\n          required: false\n          schema:\n            type: string\n          description: The unique identifier of the resource where the webhook was created\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/GithubReleaseWebhook\"\n      responses:\n        \"200\":\n          description: Webhook processed successfully\n        \"400\":\n          description: Bad request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"422\":\n          description: Validation failed or endpoint has been spammed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n    get:\n      summary: Get release notes\n      operationId: getReleaseNotes\n      description: Fetch release notes from Strapi with caching\n      tags:\n        - Releases\n      parameters:\n        - in: query\n          name: project\n          required: true\n          schema:\n            type: string\n            enum: [comfyui, comfyui_frontend, desktop, cloud]\n          description: The project to get release notes for\n        - in: query\n          name: current_version\n          required: false\n          schema:\n            type: string\n          description: The current version to filter release notes\n        - in: query\n          name: locale\n          required: false\n          schema:\n            type: string\n            enum: [en, es, fr, ja, ko, ru, zh]\n            default: en\n          description: The locale for the release notes\n        - in: query\n          name: form_factor\n          description: The platform requesting the release notes\n          required: false\n          schema:\n            type: string\n      responses:\n        \"200\":\n          description: Release notes retrieved successfully\n          content:\n            application/json:\n              schema:\n                type: array\n                items:\n                  $ref: \"#/components/schemas/ReleaseNote\"\n        \"400\":\n          description: Bad request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /security-scan:\n    get:\n      summary: Security Scan\n      operationId: securityScan\n      description: Pull all pending node versions and conduct security scans.\n      tags:\n        - Registry\n      x-excluded: true\n      parameters:\n        - in: query\n          name: minAge\n          required: false\n          schema:\n            type: string\n            x-go-type: time.Duration\n        - in: query\n          name: minSecurityScanAge\n          required: false\n          schema:\n            type: string\n            x-go-type: time.Duration\n        - in: query\n          name: maxNodes\n          required: false\n          schema:\n            type: integer\n      responses:\n        \"200\":\n          description: Scan completed successfully\n        \"400\":\n          description: Bad request, invalid input data.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /nodes/{nodeId}/versions/{version}/comfy-nodes:\n    parameters:\n      - in: path\n        name: nodeId\n        required: true\n        schema:\n          type: string\n      - in: path\n        name: version\n        required: true\n        schema:\n          type: string\n    post:\n      summary: create comfy-nodes for certain node\n      operationId: CreateComfyNodes\n      tags:\n        - Registry\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                success:\n                  type: boolean\n                status:\n                  type: string\n                reason:\n                  type: string\n                cloud_build_info:\n                  $ref: \"#/components/schemas/ComfyNodeCloudBuildInfo\"\n                nodes:\n                  additionalProperties:\n                    $ref: \"#/components/schemas/ComfyNode\"\n      responses:\n        \"204\":\n          description: Comy Nodes created successfully\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: Version not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"409\":\n          description: Existing Comfy Nodes exists\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n    get:\n      summary: list comfy-nodes for node version\n      operationId: ListComfyNodes\n      tags:\n        - Registry\n      parameters:\n        - in: query\n          name: page\n          required: false\n          schema:\n            type: integer\n            default: 1\n          description: The page number to retrieve.\n        - in: query\n          name: limit\n          required: false\n          schema:\n            type: integer\n            default: 10\n          description: The number of items to include per page.\n      responses:\n        \"200\":\n          description: Comy Nodes obtained successfully\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  comfy_nodes:\n                    type: array\n                    items:\n                      $ref: \"#/components/schemas/ComfyNode\"\n                  totalNumberOfPages:\n                    type: integer\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: Version not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /nodes/{nodeId}/versions/{version}/comfy-nodes/{comfyNodeName}:\n    get:\n      summary: get specify comfy-node based on its id\n      operationId: GetComfyNode\n      tags:\n        - Registry\n      parameters:\n        - in: path\n          name: nodeId\n          required: true\n          schema:\n            type: string\n        - in: path\n          name: version\n          required: true\n          schema:\n            type: string\n        - in: path\n          name: comfyNodeName\n          required: true\n          schema:\n            type: string\n      responses:\n        \"200\":\n          description: Comy Nodes created successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ComfyNode\"\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: Version not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n    put:\n      summary: Update a specific comfy-node\n      operationId: UpdateComfyNode\n      tags:\n        - Registry\n      parameters:\n        - in: path\n          name: nodeId\n          required: true\n          schema:\n            type: string\n        - in: path\n          name: version\n          required: true\n          schema:\n            type: string\n        - in: path\n          name: comfyNodeName\n          required: true\n          schema:\n            type: string\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: '#/components/schemas/ComfyNodeUpdateRequest'\n      responses:\n        '200':\n          description: Comfy Node updated successfully\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/ComfyNode'\n        '400':\n          description: Bad request, invalid input data\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/ErrorResponse'\n        '401':\n          description: Unauthorized\n        '403':\n          description: Forbidden\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/ErrorResponse'\n        '404':\n          description: ComfyNode not found\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/ErrorResponse'\n        '500':\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/ErrorResponse'\n  /comfy-nodes:\n    get:\n      summary: list all comfy-nodes\n      operationId: ListAllComfyNodes\n      tags:\n        - Registry\n      parameters:\n        - in: query\n          name: pageSize\n          required: false\n          schema:\n            type: integer\n            default: 100\n        - in: query\n          name: page\n          required: false\n          description: Page number (1-based indexing)\n          schema:\n            type: integer\n            default: 1\n        - in: query\n          name: node_id\n          required: false\n          description: Filter by node ID\n          schema:\n            type: string\n        - in: query\n          name: node_version\n          required: false\n          description: Filter by node version\n          schema:\n            type: string\n        - in: query\n          name: comfy_node_name\n          required: false\n          description: Filter by ComfyUI node name\n          schema:\n            type: string\n      responses:\n        '200':\n          description: OK\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  comfy_nodes:\n                    type: array\n                    items:\n                      $ref: '#/components/schemas/ComfyNode'\n                  total:\n                    type: integer\n                    description: Total number of comfy nodes\n        '400':\n          description: Bad request, invalid input data.\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/ErrorResponse'\n        '401':\n          description: Unauthorized\n        '403':\n          description: Forbidden\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/ErrorResponse'\n        '500':\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/ErrorResponse'\n  /comfy-nodes/backfill:\n    post:\n      summary: trigger comfy nodes backfill\n      operationId: ComfyNodesBackfill\n      tags:\n        - Registry\n      x-excluded: true\n      parameters:\n        - in: query\n          name: max_node\n          required: false\n          schema:\n            type: integer\n            default: 10\n      responses:\n        \"204\":\n          description: Backfill triggered\n        \"400\":\n          description: Bad request, invalid input data.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/dummy:\n    post:\n      summary: Dummy proxy\n      description: Dummy proxy endpoint that returns a simple string\n      operationId: dummyProxy\n      x-excluded: true\n      tags:\n        - API Nodes\n      requestBody:\n        content:\n          application/json:\n            schema:\n              type: object\n              properties:\n                message:\n                  type: string\n      responses:\n        \"200\":\n          description: Reindex completed successfully.\n  /proxy/minimax/video_generation:\n    post:\n      summary: Proxy request to Minimax for video generation\n      description: Forwards video generation requests to Minimax's API and returns the task ID for asynchronous processing.\n      operationId: minimaxVideoGeneration\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/MinimaxVideoGenerationRequest\"\n\n      responses:\n        \"200\":\n          description: Successful response from Minimax proxy\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/MinimaxVideoGenerationResponse\"\n        \"400\":\n          description: Bad Request (invalid input to proxy)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"429\":\n          description: Rate limit exceeded (either from proxy or Minimax)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error (proxy or upstream issue)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"502\":\n          description: Bad Gateway (error communicating with Minimax)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"504\":\n          description: Gateway Timeout (Minimax took too long to respond)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/minimax/query/video_generation:\n    get:\n      summary: Query status of a Minimax video generation task\n      description: Proxies a request to Minimax to check the status of a video generation task\n      operationId: getMinimaxVideoGeneration\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: task_id\n          in: query\n          description: The task ID to be queried\n          required: true\n          schema:\n            type: string\n      responses:\n        \"200\":\n          description: Successful response with task status\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/MinimaxTaskResultResponse\"\n        \"400\":\n          description: Bad Request (invalid input to proxy)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"429\":\n          description: Rate limit exceeded (either from proxy or Minimax)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error (proxy or upstream issue)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"502\":\n          description: Bad Gateway (error communicating with Minimax)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"504\":\n          description: Gateway Timeout (Minimax took too long to respond)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/minimax/files/retrieve:\n    post:\n      summary: Retrieve download URL for a Minimax file\n      description: Proxies a request to Minimax to get the download URL for a file\n      operationId: retrieveMinimaxFile\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      parameters:\n        - in: query\n          name: file_id\n          required: true\n          schema:\n            type: integer\n          description: Unique identifier for the file, obtained from the generation response\n      responses:\n        \"200\":\n          description: Successful response with file download URL\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/MinimaxFileRetrieveResponse\"\n        \"400\":\n          description: Bad Request (invalid input to proxy)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"429\":\n          description: Rate limit exceeded (either from proxy or Minimax)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error (proxy or upstream issue)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"502\":\n          description: Bad Gateway (error communicating with Minimax)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"504\":\n          description: Gateway Timeout (Minimax took too long to respond)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/ideogram/generate:\n    post:\n      summary: Proxy request to Ideogram for image generation\n      description: Forwards image generation requests to Ideogram's API and returns the results.\n      operationId: ideogramGenerate\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/IdeogramGenerateRequest\"\n      responses:\n        \"200\":\n          description: Successful response from Ideogram proxy\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/IdeogramGenerateResponse\"\n        \"400\":\n          description: Bad Request (invalid input to proxy)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"429\":\n          description: Rate limit exceeded (either from proxy or Ideogram)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error (proxy or upstream issue)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"502\":\n          description: Bad Gateway (error communicating with Ideogram)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"504\":\n          description: Gateway Timeout (Ideogram took too long to respond)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/ideogram/ideogram-v3/generate:\n    post:\n      summary: Proxy request to Ideogram for image generation\n      description: Forwards image generation requests to Ideogram's API and returns the results.\n      operationId: ideogramV3Generate\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        description: Parameters for Ideogram V3 image generation\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/IdeogramV3Request\"\n      responses:\n        \"200\":\n          description: Successful response from Ideogram proxy\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/IdeogramGenerateResponse\"\n        \"500\":\n          description: Internal Server Error (proxy or upstream issue)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/ideogram/ideogram-v3/edit:\n    post:\n      summary: Proxy request to Ideogram for image editing\n      description: Forwards image editing requests to Ideogram's API and returns the results.\n      operationId: ideogramV3Edit\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        description: Parameters for Ideogram V3 image editing\n        required: true\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/IdeogramV3EditRequest\"\n      responses:\n        \"200\":\n          description: Successful response from Ideogram proxy\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/IdeogramGenerateResponse\"\n        \"400\":\n          description: Bad Request (invalid input to proxy)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"422\":\n          description: Prompt or Initial Image failed the safety checks.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"429\":\n          description: Rate limit exceeded (either from proxy or Ideogram)\n        \"500\":\n          description: Internal Server Error (proxy or upstream issue)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/ideogram/ideogram-v3/remix:\n    post:\n      summary: Remix an image using a prompt\n      operationId: ideogramV3Remix\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        required: true\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/IdeogramV3RemixRequest\"\n      responses:\n        \"200\":\n          description: Remix generated successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/IdeogramV3IdeogramResponse\"\n        \"400\":\n          description: Bad Request\n        \"403\":\n          description: Forbidden\n        \"422\":\n          description: Unprocessable Entity\n        \"429\":\n          description: Too Many Requests\n      parameters: []\n  /proxy/ideogram/ideogram-v3/reframe:\n    post:\n      summary: Reframe an image to a chosen resolution\n      operationId: ideogramV3Reframe\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        required: true\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/IdeogramV3ReframeRequest\"\n      responses:\n        \"200\":\n          description: Reframed image successfully returned\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/IdeogramV3IdeogramResponse\"\n        \"400\":\n          description: Bad Request\n        \"401\":\n          description: Unauthorized\n        \"422\":\n          description: Unprocessable Entity\n        \"429\":\n          description: Too Many Requests\n      parameters: []\n  /proxy/ideogram/ideogram-v3/replace-background:\n    post:\n      summary: Replace background of an image using a prompt\n      operationId: ideogramV3ReplaceBackground\n      tags:\n        - API Nodes\n        - Released\n      x-excluded: true\n      requestBody:\n        required: true\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/IdeogramV3ReplaceBackgroundRequest\"\n      responses:\n        \"200\":\n          description: Background replaced successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/IdeogramV3IdeogramResponse\"\n        \"400\":\n          description: Bad Request\n        \"401\":\n          description: Unauthorized\n        \"422\":\n          description: Unprocessable Entity\n        \"429\":\n          description: Too Many Requests\n      parameters: []\n\n  /proxy/kling/v1/account/costs:\n    get:\n      summary: KlingAI Query Resource Package Information\n      operationId: klingQueryResourcePackages\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: start_time\n          in: query\n          required: true\n          schema:\n            type: integer\n            description: Start time for the query, Unix timestamp in ms\n        - name: end_time\n          in: query\n          required: true\n          schema:\n            type: integer\n            description: End time for the query, Unix timestamp in ms\n        - name: resource_pack_name\n          in: query\n          required: false\n          schema:\n            type: string\n            description: Resource package name for precise querying of a specific package\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingResourcePackageResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n  /proxy/kling/v1/videos/text2video:\n    post:\n      summary: KlingAI Create Video from Text\n      operationId: klingCreateVideoFromText\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        description: Create task for generating video from text\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/KlingText2VideoRequest\"\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingText2VideoResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n    get:\n      summary: KlingAI Query Task List\n      operationId: klingText2VideoQueryTaskList\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: pageNum\n          in: query\n          description: Page number\n          required: false\n          schema:\n            type: integer\n            default: 1\n            minimum: 1\n            maximum: 1000\n        - name: pageSize\n          in: query\n          description: Data volume per page\n          required: false\n          schema:\n            type: integer\n            default: 30\n            minimum: 1\n            maximum: 500\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingText2VideoResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n  /proxy/kling/v1/videos/text2video/{id}:\n    get:\n      summary: KlingAI Query Single Task\n      operationId: klingText2VideoQuerySingleTask\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: Task ID or external_task_id\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingText2VideoResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n  /proxy/kling/v1/videos/image2video:\n    post:\n      summary: KlingAI Create Video from Image\n      operationId: klingCreateVideoFromImage\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        description: Create task for generating video from image\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/KlingImage2VideoRequest\"\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingImage2VideoResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n    get:\n      summary: KlingAI Query Image2Video Task List\n      operationId: klingImage2VideoQueryTaskList\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: pageNum\n          in: query\n          description: Page number\n          required: false\n          schema:\n            type: integer\n            default: 1\n            minimum: 1\n            maximum: 1000\n        - name: pageSize\n          in: query\n          description: Data volume per page\n          required: false\n          schema:\n            type: integer\n            default: 30\n            minimum: 1\n            maximum: 500\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingImage2VideoResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n  /proxy/kling/v1/videos/image2video/{id}:\n    get:\n      summary: KlingAI Query Single Image2Video Task\n      operationId: klingImage2VideoQuerySingleTask\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: Task ID or external_task_id\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingImage2VideoResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n  /proxy/kling/v1/videos/video-extend:\n    post:\n      summary: KlingAI Extend Video Duration\n      operationId: klingExtendVideo\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        description: Create task for extending video duration\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/KlingVideoExtendRequest\"\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingVideoExtendResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n    get:\n      summary: KlingAI Query Video-Extend Task List\n      operationId: klingVideoExtendQueryTaskList\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: pageNum\n          in: query\n          description: Page number\n          required: false\n          schema:\n            type: integer\n            default: 1\n            minimum: 1\n            maximum: 1000\n        - name: pageSize\n          in: query\n          description: Data volume per page\n          required: false\n          schema:\n            type: integer\n            default: 30\n            minimum: 1\n            maximum: 500\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingVideoExtendResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n  /proxy/kling/v1/videos/video-extend/{id}:\n    get:\n      summary: KlingAI Query Single Video-Extend Task\n      operationId: klingVideoExtendQuerySingleTask\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: Task ID\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingVideoExtendResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n  /proxy/kling/v1/videos/lip-sync:\n    post:\n      summary: KlingAI Create Lip-Sync Video\n      operationId: klingCreateLipSyncVideo\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        description: Create task for generating lip-sync video\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/KlingLipSyncRequest\"\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingLipSyncResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n    get:\n      summary: KlingAI Query Lip-Sync Task List\n      operationId: klingLipSyncQueryTaskList\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: pageNum\n          in: query\n          description: Page number\n          required: false\n          schema:\n            type: integer\n            default: 1\n            minimum: 1\n            maximum: 1000\n        - name: pageSize\n          in: query\n          description: Data volume per page\n          required: false\n          schema:\n            type: integer\n            default: 30\n            minimum: 1\n            maximum: 500\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingLipSyncResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n  /proxy/kling/v1/videos/lip-sync/{id}:\n    get:\n      summary: KlingAI Query Single Lip-Sync Task\n      operationId: klingLipSyncQuerySingleTask\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: Task ID or external_task_id\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingLipSyncResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n  /proxy/kling/v1/videos/effects:\n    post:\n      summary: KlingAI Create Video Effects Task\n      operationId: klingCreateVideoEffects\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        description: Create task for generating video with effects\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/KlingVideoEffectsRequest\"\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingVideoEffectsResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n    get:\n      summary: KlingAI Query Video Effects Task List\n      operationId: klingVideoEffectsQueryTaskList\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: pageNum\n          in: query\n          description: Page number\n          required: false\n          schema:\n            type: integer\n            default: 1\n            minimum: 1\n            maximum: 1000\n        - name: pageSize\n          in: query\n          description: Data volume per page\n          required: false\n          schema:\n            type: integer\n            default: 30\n            minimum: 1\n            maximum: 500\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingVideoEffectsResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n  /proxy/kling/v1/videos/effects/{id}:\n    get:\n      summary: KlingAI Query Single Video Effects Task\n      operationId: klingVideoEffectsQuerySingleTask\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: Task ID or external_task_id\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingVideoEffectsResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n  /proxy/kling/v1/videos/motion-control:\n    post:\n      summary: KlingAI Create Motion Control Task\n      operationId: klingCreateMotionControl\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        description: Create task for generating motion control video\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/KlingMotionControlRequest\"\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingMotionControlResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n  /proxy/kling/v1/videos/motion-control/{id}:\n    get:\n      summary: KlingAI Query Single Motion Control Task\n      operationId: klingMotionControlQuerySingleTask\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: Task ID or external_task_id\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingMotionControlResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n  /proxy/kling/v1/videos/omni-video:\n    post:\n      summary: KlingAI Create Omni-Video Task\n      operationId: klingCreateOmniVideo\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        description: Create task for generating omni-video\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/KlingOmniVideoRequest\"\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingOmniVideoResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n  /proxy/kling/v1/videos/omni-video/{id}:\n    get:\n      summary: KlingAI Query Single Omni-Video Task\n      operationId: klingOmniVideoQuerySingleTask\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: Task ID or External Task ID. Can query by either task_id (generated by system) or external_task_id (customized task ID)\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingOmniVideoResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n  /proxy/kling/v1/videos/avatar/image2video:\n    post:\n      summary: KlingAI Create Avatar Video\n      operationId: klingCreateAvatarVideo\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        description: Create task for generating avatar video from image and audio\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/KlingAvatarRequest\"\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingAvatarResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n  /proxy/kling/v1/videos/avatar/image2video/{id}:\n    get:\n      summary: KlingAI Query Avatar Task\n      operationId: klingAvatarQueryTask\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: Task ID or external_task_id\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingAvatarResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n  /proxy/kling/v1/images/generations:\n    post:\n      summary: KlingAI Create Image Generation Task\n      operationId: klingCreateImageGeneration\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        description: Create task for generating images\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/KlingImageGenerationsRequest\"\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingImageGenerationsResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n    get:\n      summary: KlingAI Query Image Generation Task List\n      operationId: klingImageGenerationsQueryTaskList\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: pageNum\n          in: query\n          description: Page number\n          required: false\n          schema:\n            type: integer\n            default: 1\n            minimum: 1\n            maximum: 1000\n        - name: pageSize\n          in: query\n          description: Data volume per page\n          required: false\n          schema:\n            type: integer\n            default: 30\n            minimum: 1\n            maximum: 500\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingImageGenerationsResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n  /proxy/kling/v1/images/generations/{id}:\n    get:\n      summary: KlingAI Query Single Image Generation Task\n      operationId: klingImageGenerationsQuerySingleTask\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: Task ID\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingImageGenerationsResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n  /proxy/kling/v1/images/omni-image:\n    post:\n      summary: KlingAI Create Omni-Image Task\n      operationId: klingCreateOmniImage\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        description: Create task for generating omni-image\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/KlingOmniImageRequest\"\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingOmniImageResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n  /proxy/kling/v1/images/omni-image/{id}:\n    get:\n      summary: KlingAI Query Single Omni-Image Task\n      operationId: klingOmniImageQuerySingleTask\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: Task ID or External Task ID. Can query by either task_id (generated by system) or external_task_id (customized task ID)\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingOmniImageResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n  /proxy/kling/v1/images/kolors-virtual-try-on:\n    post:\n      summary: KlingAI Create Virtual Try-On Task\n      operationId: klingCreateVirtualTryOn\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        description: Create task for virtual try-on of clothing on human images\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/KlingVirtualTryOnRequest\"\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingVirtualTryOnResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n    get:\n      summary: KlingAI Query Virtual Try-On Task List\n      operationId: klingVirtualTryOnQueryTaskList\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: pageNum\n          in: query\n          description: Page number\n          required: false\n          schema:\n            type: integer\n            default: 1\n            minimum: 1\n            maximum: 1000\n        - name: pageSize\n          in: query\n          description: Data volume per page\n          required: false\n          schema:\n            type: integer\n            default: 30\n            minimum: 1\n            maximum: 500\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingVirtualTryOnResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n  /proxy/kling/v1/images/kolors-virtual-try-on/{id}:\n    get:\n      summary: KlingAI Query Single Virtual Try-On Task\n      operationId: klingVirtualTryOnQuerySingleTask\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: Task ID\n      responses:\n        \"200\":\n          description: Successful response (Request successful)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingVirtualTryOnResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/KlingErrorResponse\"\n  /proxy/ltx/v1/text-to-video:\n    post:\n      summary: LTX Video Generate Video from Text\n      description: Generate a video from a text prompt using LTX Video AI models\n      operationId: ltxCreateVideoFromText\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        description: Create video from text prompt\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/LTXText2VideoRequest\"\n      responses:\n        \"200\":\n          description: Video generated successfully\n          content:\n            video/mp4:\n              schema:\n                type: string\n                format: binary\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/ltx/v1/image-to-video:\n    post:\n      summary: LTX Video Generate Video from Image\n      description: Transform a static image into a dynamic video using LTX Video AI models\n      operationId: ltxCreateVideoFromImage\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        description: Create video from image\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/LTXImage2VideoRequest\"\n      responses:\n        \"200\":\n          description: Video generated successfully\n          content:\n            video/mp4:\n              schema:\n                type: string\n                format: binary\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/bfl/flux-kontext-pro/generate:\n    post:\n      summary: Proxy request to BFL Flux Kontext Pro for image editing\n      description: Forwards image editing requests to BFL's Flux Kontext Pro API and returns the results.\n      operationId: bflFluxKontextProGenerate\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/BFLFluxKontextProGenerateRequest\"\n      responses:\n        \"200\":\n          description: Successful response from BFL Flux Kontext Pro proxy\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BFLFluxKontextProGenerateResponse\"\n        \"400\":\n          description: Bad Request (invalid input to proxy)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"429\":\n          description: Rate limit exceeded (either from proxy or BFL)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error (proxy or upstream issue)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"502\":\n          description: Bad Gateway (error communicating with BFL)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"504\":\n          description: Gateway Timeout (BFL took too long to respond)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/bfl/flux-kontext-max/generate:\n    post:\n      summary: Proxy request to BFL Flux Kontext Max for image editing\n      description: Forwards image editing requests to BFL's Flux Kontext Max API and returns the results.\n      operationId: bflFluxKontextMaxGenerate\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/BFLFluxKontextMaxGenerateRequest\"\n      responses:\n        \"200\":\n          description: Successful response from BFL Flux Kontext Max proxy\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BFLFluxKontextMaxGenerateResponse\"\n        \"400\":\n          description: Bad Request (invalid input to proxy)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"429\":\n          description: Rate limit exceeded (either from proxy or BFL)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error (proxy or upstream issue)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"502\":\n          description: Bad Gateway (error communicating with BFL)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"504\":\n          description: Gateway Timeout (BFL took too long to respond)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/bfl/flux-pro-1.1/generate:\n    post:\n      summary: Proxy request to BFL Flux Pro 1.1 for image generation\n      description: Forwards image generation requests to BFL's Flux Pro 1.1 API and returns the results.\n      operationId: bflFluxPro1_1Generate\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/BFLFluxPro1_1GenerateRequest\"\n      responses:\n        \"200\":\n          description: Successful response from BFL Flux Pro proxy\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BFLFluxPro1_1GenerateResponse\"\n        \"400\":\n          description: Bad Request (invalid input to proxy)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"429\":\n          description: Rate limit exceeded (either from proxy or BFL)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error (proxy or upstream issue)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"502\":\n          description: Bad Gateway (error communicating with BFL)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"504\":\n          description: Gateway Timeout (BFL took too long to respond)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/bfl/flux-pro-1.1-ultra/generate:\n    post:\n      summary: Proxy request to BFL Flux Pro 1.1 Ultra for image generation\n      description: Forwards image generation requests to BFL's Flux Pro 1.1 Ultra API and returns the results.\n      operationId: bflFluxProGenerate\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/BFLFluxProGenerateRequest\"\n      responses:\n        \"200\":\n          description: Successful response from BFL Flux Pro proxy\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BFLFluxProGenerateResponse\"\n        \"400\":\n          description: Bad Request (invalid input to proxy)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"429\":\n          description: Rate limit exceeded (either from proxy or BFL)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error (proxy or upstream issue)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"502\":\n          description: Bad Gateway (error communicating with BFL)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"504\":\n          description: Gateway Timeout (BFL took too long to respond)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/bfl/flux-2-pro/generate:\n    post:\n      summary: Proxy request to BFL Flux 2 Pro for image generation\n      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.\n      operationId: bflFlux2ProGenerate\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/BFLFlux2ProGenerateRequest\"\n      responses:\n        \"200\":\n          description: Successful response from BFL Flux 2 Pro proxy\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BFLFluxProGenerateResponse\"\n        \"400\":\n          description: Bad Request (invalid input to proxy)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"429\":\n          description: Rate limit exceeded (either from proxy or BFL)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error (proxy or upstream issue)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"502\":\n          description: Bad Gateway (error communicating with BFL)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"504\":\n          description: Gateway Timeout (BFL took too long to respond)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/bfl/flux-2-max/generate:\n    post:\n      summary: Proxy request to BFL Flux 2 Max for image generation\n      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.\n      operationId: bflFlux2MaxGenerate\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/BFLFlux2ProGenerateRequest\"\n      responses:\n        \"200\":\n          description: Successful response from BFL Flux 2 Max proxy\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BFLFluxProGenerateResponse\"\n        \"400\":\n          description: Bad Request (invalid input to proxy)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"429\":\n          description: Rate limit exceeded (either from proxy or BFL)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error (proxy or upstream issue)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"502\":\n          description: Bad Gateway (error communicating with BFL)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"504\":\n          description: Gateway Timeout (BFL took too long to respond)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/bfl/flux-pro-1.0-expand/generate:\n    post:\n      tags:\n        - API Nodes\n        - Released\n      summary: Expand an image by adding pixels on any side.\n      x-excluded: true\n      description: >-\n        Submits an image expansion task that adds the specified number of pixels to any combination of sides (top, bottom, left, right) while maintaining context.\n      operationId: BFLExpand_v1_flux_pro_1_0_expand_post\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/BFLFluxProExpandInputs\"\n      responses:\n        \"200\":\n          description: Successful Response\n          content:\n            application/json:\n              schema:\n                anyOf:\n                  - $ref: \"#/components/schemas/BFLAsyncResponse\"\n                  - $ref: \"#/components/schemas/BFLAsyncWebhookResponse\"\n                title: Response Expand V1 Flux Pro 1 0 Expand Post\n        \"422\":\n          description: Validation Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BFLHTTPValidationError\"\n      parameters: []\n  /proxy/bfl/flux-pro-1.0-fill/generate:\n    post:\n      tags:\n        - API Nodes\n        - Released\n      summary: \"Generate an image with FLUX.1 Fill [pro] using an input image and mask.\"\n      x-excluded: true\n      description: >-\n        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.\n      operationId: BFLFill_v1_flux_pro_1_0_fill_post\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/BFLFluxProFillInputs\"\n      responses:\n        \"200\":\n          description: Successful Response\n          content:\n            application/json:\n              schema:\n                anyOf:\n                  - $ref: \"#/components/schemas/BFLAsyncResponse\"\n                  - $ref: \"#/components/schemas/BFLAsyncWebhookResponse\"\n                title: Response Fill V1 Flux Pro 1 0 Fill Post\n        \"422\":\n          description: Validation Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BFLHTTPValidationError\"\n      parameters: []\n  /proxy/bfl/flux-pro-1.0-canny/generate:\n    post:\n      tags:\n        - API Nodes\n        - Released\n      x-excluded: true\n      summary: \"Generate an image with FLUX.1 Canny [pro] using a control image.\"\n      description: \"Submits an image generation task with FLUX.1 Canny [pro].\"\n      operationId: BFLPro_canny_v1_flux_pro_1_0_canny_post\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/BFLCannyInputs\"\n      responses:\n        \"200\":\n          description: Successful Response\n          content:\n            application/json:\n              schema:\n                anyOf:\n                  - $ref: \"#/components/schemas/BFLAsyncResponse\"\n                  - $ref: \"#/components/schemas/BFLAsyncWebhookResponse\"\n                title: Response Pro Canny V1 Flux Pro 1 0 Canny Post\n        \"422\":\n          description: Validation Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BFLHTTPValidationError\"\n      parameters: []\n  /proxy/bfl/flux-pro-1.0-depth/generate:\n    post:\n      tags:\n        - API Nodes\n        - Released\n      x-excluded: true\n      summary: \"Generate an image with FLUX.1 Depth [pro] using a control image.\"\n      description: \"Submits an image generation task with FLUX.1 Depth [pro].\"\n      operationId: BFLPro_depth_v1_flux_pro_1_0_depth_post\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/BFLDepthInputs\"\n      responses:\n        \"200\":\n          description: Successful Response\n          content:\n            application/json:\n              schema:\n                anyOf:\n                  - $ref: \"#/components/schemas/BFLAsyncResponse\"\n                  - $ref: \"#/components/schemas/BFLAsyncWebhookResponse\"\n                title: Response Pro Depth V1 Flux Pro 1 0 Depth Post\n        \"422\":\n          description: Validation Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BFLHTTPValidationError\"\n      parameters: []\n  /proxy/luma/generations:\n    post:\n      summary: Create a generation\n      description: Initiate a new generation with the provided prompt\n      operationId: lumaCreateGeneration\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        required: true\n        description: The generation request object\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/LumaGenerationRequest\"\n            examples:\n              default:\n                value:\n                  prompt: \"A serene lake surrounded by mountains at sunset\"\n                  aspect_ratio: \"16:9\"\n                  loop: true\n                  keyframes:\n                    frame0:\n                      type: image\n                      url: \"https://example.com/image.jpg\"\n                    frame1:\n                      type: generation\n                      id: \"123e4567-e89b-12d3-a456-426614174000\"\n      responses:\n        default:\n          description: Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/LumaError\"\n        \"201\":\n          description: Generation created\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/LumaGeneration\"\n      parameters: []\n  /proxy/luma/generations/{id}:\n    get:\n      summary: Get a generation\n      description: Retrieve details of a specific generation by its ID\n      operationId: lumaGetGeneration\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: The ID of the generation\n      responses:\n        default:\n          description: Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/LumaError\"\n        \"200\":\n          description: Generation found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/LumaGeneration\"\n\n  /proxy/luma/generations/image:\n    post:\n      summary: Generate an image\n      description: Generate an image with the provided prompt\n      operationId: lumaGenerateImage\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        required: true\n        description: The image generation request object\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/LumaImageGenerationRequest\"\n      responses:\n        default:\n          description: Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/LumaError\"\n        \"201\":\n          description: Image generated\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/LumaGeneration\"\n      parameters: []\n  /proxy/pixverse/video/text/generate:\n    post:\n      summary: Generate video from text prompt.\n      operationId: PixverseGenerateTextVideo\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - $ref: \"#/components/parameters/PixverseAiTraceId\"\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/PixverseTextVideoRequest\"\n      responses:\n        \"200\":\n          description: Success\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/PixverseVideoResponse\"\n        \"400\":\n          description: Bad Request (invalid input to proxy)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"429\":\n          description: Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error (proxy or upstream issue)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/pixverse/video/img/generate:\n    post:\n      summary: Generate video from image.\n      operationId: PixverseGenerateImageVideo\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - $ref: \"#/components/parameters/PixverseAiTraceId\"\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/PixverseImageVideoRequest\"\n      responses:\n        \"200\":\n          description: Success\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/PixverseVideoResponse\"\n        \"400\":\n          description: Bad Request (invalid input to proxy)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"429\":\n          description: Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error (proxy or upstream issue)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/pixverse/video/transition/generate:\n    post:\n      summary: Generate transition video between two images.\n      operationId: PixverseGenerateTransitionVideo\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - $ref: \"#/components/parameters/PixverseAiTraceId\"\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/PixverseTransitionVideoRequest\"\n      responses:\n        \"200\":\n          description: Success\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/PixverseVideoResponse\"\n        \"400\":\n          description: Bad Request (invalid input to proxy)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"429\":\n          description: Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error (proxy or upstream issue)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/pixverse/image/upload:\n    post:\n      summary: Upload an image to the server.\n      operationId: PixverseUploadImage\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - $ref: \"#/components/parameters/PixverseAiTraceId\"\n      requestBody:\n        required: true\n        content:\n          multipart/form-data:\n            schema:\n              type: object\n              properties:\n                image:\n                  type: string\n                  format: binary\n      responses:\n        \"200\":\n          description: Image uploaded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/PixverseImageUploadResponse\"\n        \"400\":\n          description: Bad Request (invalid input to proxy)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"429\":\n          description: Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error (proxy or upstream issue)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/pixverse/video/result/{id}:\n    get:\n      summary: Get the result of a video generation.\n      operationId: PixverseGetVideoResult\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - $ref: \"#/components/parameters/PixverseAiTraceId\"\n        - name: id\n          in: path\n          required: true\n          schema:\n            type: integer\n      responses:\n        \"200\":\n          description: Result fetched\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/PixverseVideoResultResponse\"\n\n  /webhook/metronome/zero-balance:\n    post:\n      summary: receive alert on remaining balance is 0\n      operationId: metronomeZeroBalance\n      x-excluded: true\n      tags:\n        - Webhook\n        - Metronome\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              type: object\n              required: [id, type, properties]\n              properties:\n                id:\n                  type: string\n                  description: the id of the webhook\n                type:\n                  type: string\n                  description: the type of the webhook\n                properties:\n                  type: object\n                  properties:\n                    customer_id:\n                      type: string\n                      description: the metronome customer id\n                    remaining_balance:\n                      type: number\n                      description: the customer remaining balance\n      responses:\n        \"200\":\n          description: Webhook processed succesfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/IdeogramGenerateResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"500\":\n          description: Internal Server Error (proxy or upstream issue)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /webhook/stripe/invoice-status:\n    post:\n      summary: Handle Stripe invoice.paid webhook event\n      operationId: StripeInvoiceStatus\n      x-excluded: true\n      tags:\n        - Billing\n        - Stripe\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/StripeEvent\"\n      responses:\n        \"200\":\n          description: Webhook processed successfully\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"500\":\n          description: Internal Server Error (proxy or upstream issue)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /webhook/stripe/subscription:\n    post:\n      summary: Handle Stripe subscription webhook events\n      operationId: StripeSubscriptionWebhook\n      x-excluded: true\n      tags:\n        - Billing\n        - Stripe\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              type: object\n              description: Generic Stripe webhook event payload\n      responses:\n        \"200\":\n          description: Webhook processed successfully\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"500\":\n          description: Internal Server Error (proxy or upstream issue)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/recraft/image_generation:\n    post:\n      summary: Proxy request to Recraft for image generation\n      description: Forwards image generation requests to Recraft's API and returns the generated images.\n      operationId: recraftImageGeneration\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/RecraftImageGenerationRequest\"\n      responses:\n        \"200\":\n          description: Successful response from Recraft proxy\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/RecraftImageGenerationResponse\"\n        \"400\":\n          description: Bad Request (invalid input to proxy)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"429\":\n          description: Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error (proxy or upstream issue)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"502\":\n          description: Bad Gateway (error communicating with Recraft)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"504\":\n          description: Gateway Timeout (Recraft took too long to respond)\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/recraft/images/vectorize:\n    post:\n      summary: Vectorize an image\n      operationId: recraftVectorize\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        required: true\n        content:\n          multipart/form-data:\n            schema:\n              type: object\n              properties:\n                file:\n                  type: string\n                  format: binary\n                  description: Image file to process\n              required:\n                - file\n      responses:\n        \"200\":\n          description: Background removed successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/RecraftImageGenerationResponse\"\n        \"401\":\n          description: Unauthorized - Invalid or missing API token\n        \"400\":\n          description: Bad request - Invalid parameters or file\n      security: []\n  /proxy/recraft/images/crispUpscale:\n    post:\n      summary: Upscale an image\n      operationId: recraftCrispUpscale\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        required: true\n        content:\n          multipart/form-data:\n            schema:\n              type: object\n              properties:\n                file:\n                  type: string\n                  format: binary\n                  description: Image file to process\n              required:\n                - file\n      responses:\n        \"200\":\n          description: Background removed successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/RecraftImageGenerationResponse\"\n        \"401\":\n          description: Unauthorized - Invalid or missing API token\n        \"400\":\n          description: Bad request - Invalid parameters or file\n      security: []\n  /proxy/recraft/images/removeBackground:\n    post:\n      summary: Remove background from an image\n      operationId: recraftRemoveBackground\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        required: true\n        content:\n          multipart/form-data:\n            schema:\n              type: object\n              properties:\n                file:\n                  type: string\n                  format: binary\n                  description: Image file to process\n              required:\n                - file\n      responses:\n        \"200\":\n          description: Background removed successfully\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  image:\n                    type: object\n                    properties:\n                      url:\n                        type: string\n                        format: uri\n                        description: URL of the processed image\n        \"401\":\n          description: Unauthorized - Invalid or missing API token\n        \"400\":\n          description: Bad request - Invalid parameters or file\n      security: []\n  /proxy/recraft/images/imageToImage:\n    post:\n      operationId: RecraftImageToImage\n      x-excluded: true\n      requestBody:\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/RecraftImageToImageRequest\"\n      responses:\n        \"200\":\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/RecraftGenerateImageResponse\"\n          description: OK\n      summary: Generate image from image and prompt\n      tags:\n        - API Nodes\n        - Released\n      parameters: []\n  /proxy/recraft/images/inpaint:\n    post:\n      operationId: RecraftInpaintImage\n      x-excluded: true\n      requestBody:\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/RecraftTransformImageWithMaskRequest\"\n      responses:\n        \"200\":\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/RecraftGenerateImageResponse\"\n          description: OK\n      summary: Inpaint Image\n      tags:\n        - API Nodes\n        - Released\n      parameters: []\n  /proxy/recraft/images/replaceBackground:\n    post:\n      operationId: RecraftReplaceBackground\n      x-excluded: true\n      requestBody:\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/RecraftTransformImageWithMaskRequest\"\n      responses:\n        \"200\":\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/RecraftGenerateImageResponse\"\n          description: OK\n      summary: Replace Background\n      tags:\n        - API Nodes\n        - Released\n      parameters: []\n  /proxy/recraft/images/creativeUpscale:\n    post:\n      operationId: RecraftCreativeUpscale\n      x-excluded: true\n      requestBody:\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/RecraftProcessImageRequest\"\n      responses:\n        \"200\":\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/RecraftProcessImageResponse\"\n          description: OK\n      summary: Creative Upscale\n      tags:\n        - API Nodes\n        - Released\n      parameters: []\n  /proxy/recraft/styles:\n    post:\n      operationId: RecraftCreateStyle\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      summary: Create Style\n      description: Upload a set of images to create a style reference.\n      requestBody:\n        required: true\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/RecraftCreateStyleRequest\"\n      responses:\n        \"200\":\n          description: OK\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/RecraftCreateStyleResponse\"\n  /proxy/runway/image_to_video:\n    post:\n      summary: Runway Image to Video Generation\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      description: Converts an image to a video using Runway's API\n      operationId: runwayImageToVideo\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/RunwayImageToVideoRequest\"\n      responses:\n        \"200\":\n          description: Successful response\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/RunwayImageToVideoResponse\"\n        \"400\":\n          description: Bad request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/runway/tasks/{task_id}:\n    get:\n      summary: Get Runway Task Status\n      description: Get the status and output of a Runway task\n      operationId: runwayGetTaskStatus\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: task_id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: ID of the task to check\n      responses:\n        \"200\":\n          description: Successful response\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/RunwayTaskStatusResponse\"\n        \"400\":\n          description: Bad request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: Task not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/runway/text_to_image:\n    post:\n      summary: Runway Text to Image Generation\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      description: Generates an image from text using Runway's API\n      operationId: runwayTextToImage\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/RunwayTextToImageRequest\"\n      responses:\n        \"200\":\n          description: Successful response\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/RunwayTextToImageResponse\"\n        \"400\":\n          description: Bad request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/veo/generate:\n    post:\n      summary: Generate a video from a text prompt and optional image. Deprecated. Use /proxy/veo/{modelId}/generate instead.\n      operationId: veoGenerate\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/Veo2GenVidRequest\"\n      responses:\n        \"200\":\n          description: Video generation successful\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Veo2GenVidResponse\"\n        \"400\":\n          description: Bad request\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden\n        \"500\":\n          description: Internal server error\n  /proxy/veo/poll:\n    post:\n      summary: Poll the status of a Veo prediction operation. Deprecated. Use /proxy/veo/{modelId}/generate instead.\n      operationId: veoPoll\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/Veo2GenVidPollRequest\"\n      responses:\n        \"200\":\n          description: Operation status and result\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Veo2GenVidPollResponse\"\n        \"400\":\n          description: Bad request\n        \"401\":\n          description: Unauthorized\n        \"404\":\n          description: Operation not found\n        \"500\":\n          description: Internal error\n  /proxy/veo/{modelId}/generate:\n    post:\n      summary: Generate a video from a text prompt and optional image\n      operationId: veoGenerateNew\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: modelId\n          in: path\n          required: true\n          schema:\n            type: string\n          description: The ID of the model to use for generation\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/VeoGenVidRequest\"\n      responses:\n        \"200\":\n          description: Video generation successful\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/VeoGenVidResponse\"\n        \"400\":\n          description: Bad request\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden\n        \"500\":\n          description: Internal server error\n  /proxy/veo/{modelId}/poll:\n    post:\n      summary: Poll the status of a Veo prediction operation\n      operationId: veoPollNew\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: modelId\n          in: path\n          required: true\n          schema:\n            type: string\n          description: The ID of the model to use for generation\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/VeoGenVidPollRequest\"\n      responses:\n        \"200\":\n          description: Operation status and result\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/VeoGenVidPollResponse\"\n        \"400\":\n          description: Bad request\n        \"401\":\n          description: Unauthorized\n        \"404\":\n          description: Operation not found\n        \"500\":\n          description: Internal error\n  /proxy/openai/v1/responses:\n    post:\n      operationId: createOpenAIResponse\n      tags:\n        - API Nodes\n        - Released\n      x-excluded: true\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/OpenAICreateResponse\"\n      responses:\n        \"200\":\n          description: OK\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/OpenAIResponse\"\n            text/event-stream:\n              schema:\n                $ref: \"#/components/schemas/OpenAIResponseStreamEvent\"\n  /proxy/openai/v1/responses/{id}:\n    get:\n      operationId: getOpenAIResponse\n      tags:\n        - API Nodes\n        - Released\n      x-excluded: true\n      summary: |\n        Retrieves a model response with the given ID.\n      parameters:\n        - in: path\n          name: id\n          required: true\n          schema:\n            type: string\n            example: resp_677efb5139a88190b512bc3fef8e535d\n          description: The ID of the response to retrieve.\n        - in: query\n          name: include\n          schema:\n            type: array\n            items:\n              $ref: \"#/components/schemas/Includable\"\n          description: |\n            Additional fields to include in the response. See the `include`\n            parameter for Response creation above for more information.\n      responses:\n        \"200\":\n          description: OK\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/OpenAIResponse\"\n  /proxy/openai/images/generations:\n    post:\n      summary: Generate an image using OpenAI's models\n      operationId: openAIGenerateImage\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/OpenAIImageGenerationRequest\"\n      responses:\n        \"200\":\n          description: Image generated successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/OpenAIImageGenerationResponse\"\n        \"400\":\n          description: Bad request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/openai/images/edits:\n    post:\n      summary: Edit an image using OpenAI's DALL-E model\n      operationId: openAIEditImage\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/OpenAIImageEditRequest\"\n      responses:\n        \"200\":\n          description: Image edited successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/OpenAIImageGenerationResponse\"\n        \"400\":\n          description: Bad request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/openai/v1/videos:\n    post:\n      summary: Create a video using OpenAI's Sora model\n      operationId: openAICreateVideo\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/OpenAIVideoCreateRequest\"\n      responses:\n        \"200\":\n          description: Video generation job created successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/OpenAIVideoJob\"\n        \"400\":\n          description: Bad request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/openai/v1/videos/{video_id}:\n    get:\n      summary: Retrieve a video\n      operationId: openAIGetVideo\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      parameters:\n        - in: path\n          name: video_id\n          required: true\n          schema:\n            type: string\n          description: The identifier of the video to retrieve\n      responses:\n        \"200\":\n          description: Video job details\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/OpenAIVideoJob\"\n        \"400\":\n          description: Bad request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"404\":\n          description: Video not found\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/openai/v1/videos/{video_id}/content:\n    get:\n      summary: Download video content\n      operationId: openAIDownloadVideoContent\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      parameters:\n        - in: path\n          name: video_id\n          required: true\n          schema:\n            type: string\n          description: The identifier of the video whose media to download\n        - in: query\n          name: variant\n          schema:\n            type: string\n          description: Which downloadable asset to return\n      responses:\n        \"200\":\n          description: Video content stream\n          content:\n            video/mp4:\n              schema:\n                type: string\n                format: binary\n        \"400\":\n          description: Bad request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"404\":\n          description: Video not found\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/pika/generate/pikadditions:\n    post:\n      summary: Generate Pikadditions\n      operationId: PikaGenerate_pikadditions_generate_pikadditions_post\n      tags:\n        - API Nodes\n        - Released\n      x-excluded: true\n      requestBody:\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/PikaBody_generate_pikadditions_generate_pikadditions_post\"\n        required: true\n      responses:\n        \"200\":\n          description: Successful Response\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/PikaGenerateResponse\"\n        \"422\":\n          description: Validation Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/PikaHTTPValidationError\"\n      parameters: []\n  /proxy/pika/generate/pikaswaps:\n    post:\n      summary: Generate Pikaswaps\n      description: >-\n        Exactly one of `modifyRegionMask` and `modifyRegionRoi` must be provided.\n      tags:\n        - API Nodes\n        - Released\n      x-excluded: true\n      operationId: PikaGenerate_pikaswaps_generate_pikaswaps_post\n      requestBody:\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/PikaBody_generate_pikaswaps_generate_pikaswaps_post\"\n        required: true\n      responses:\n        \"200\":\n          description: Successful Response\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/PikaGenerateResponse\"\n        \"422\":\n          description: Validation Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/PikaHTTPValidationError\"\n      parameters: []\n  /proxy/pika/generate/pikaffects:\n    post:\n      summary: Generate Pikaffects\n      operationId: PikaGenerate_pikaffects_generate_pikaffects_post\n      tags:\n        - API Nodes\n        - Released\n      x-excluded: true\n      requestBody:\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/PikaBody_generate_pikaffects_generate_pikaffects_post\"\n        required: true\n      responses:\n        \"200\":\n          description: Successful Response\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/PikaGenerateResponse\"\n        \"422\":\n          description: Validation Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/PikaHTTPValidationError\"\n      description: >-\n        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\n      parameters: []\n  /proxy/pika/generate/2.2/t2v:\n    post:\n      summary: Generate 2 2 T2V\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      operationId: PikaGenerate_2_2_t2v_generate_2_2_t2v_post\n      requestBody:\n        content:\n          application/x-www-form-urlencoded:\n            schema:\n              $ref: \"#/components/schemas/PikaBody_generate_2_2_t2v_generate_2_2_t2v_post\"\n        required: true\n      responses:\n        \"200\":\n          description: Successful Response\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/PikaGenerateResponse\"\n        \"422\":\n          description: Validation Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/PikaHTTPValidationError\"\n      parameters: []\n  /proxy/pika/generate/2.2/pikaframes:\n    post:\n      summary: Generate 2 2 Keyframe\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      operationId: PikaGenerate_2_2_keyframe_generate_2_2_pikaframes_post\n      requestBody:\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/PikaBody_generate_2_2_keyframe_generate_2_2_pikaframes_post\"\n        required: true\n      responses:\n        \"200\":\n          description: Successful Response\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/PikaGenerateResponse\"\n        \"422\":\n          description: Validation Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/PikaHTTPValidationError\"\n      parameters: []\n  /proxy/pika/generate/2.2/pikascenes:\n    post:\n      summary: Generate 2 2 C2V\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      operationId: PikaGenerate_2_2_c2v_generate_2_2_pikascenes_post\n      requestBody:\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/PikaBody_generate_2_2_c2v_generate_2_2_pikascenes_post\"\n        required: true\n      responses:\n        \"200\":\n          description: Successful Response\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/PikaGenerateResponse\"\n        \"422\":\n          description: Validation Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/PikaHTTPValidationError\"\n      parameters: []\n  /proxy/pika/generate/2.2/i2v:\n    post:\n      summary: Generate 2 2 I2V\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      operationId: PikaGenerate_2_2_i2v_generate_2_2_i2v_post\n      requestBody:\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/PikaBody_generate_2_2_i2v_generate_2_2_i2v_post\"\n        required: true\n      responses:\n        \"200\":\n          description: Successful Response\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/PikaGenerateResponse\"\n        \"422\":\n          description: Validation Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/PikaHTTPValidationError\"\n      parameters: []\n  /proxy/pika/videos/{video_id}:\n    get:\n      summary: Get Video\n      operationId: PikaGet_video_videos__video_id__get\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: video_id\n          in: path\n          required: true\n          schema:\n            type: string\n            title: Video Id\n      responses:\n        \"200\":\n          description: Successful Response\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/PikaVideoResponse\"\n        \"422\":\n          description: Validation Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/PikaHTTPValidationError\"\n  /proxy/stability/v2beta/stable-image/generate/ultra:\n    post:\n      operationId: StabilityImageGenrationUltra\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      summary: Stable Image Ultra\n      description: >-\n        Our most advanced text to image generation service, Stable Image Ultra creates the highest quality images\n        with unprecedented prompt understanding. Ultra excels in typography, complex compositions, dynamic lighting,\n        vibrant hues, and overall cohesion and structure of an art piece. Made from the most advanced models,\n        including Stable Diffusion 3.5, Ultra offers the best of the Stable Diffusion ecosystem.\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/Stable_Image_API_Public.ipynb#scrollTo=yXhs626oZdr1)\n        ### How to use\n        Please invoke this endpoint with a `POST` request.\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`.  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 in the format specified by the `output_format` parameter, but encoded to base64 in a JSON response.\n        The only required parameter is the `prompt` field, which should contain the text prompt for the image generation.\n        The body of the request should include:\n        - `prompt` - text to generate the image from\n        The body may optionally include:\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        - `aspect_ratio` - the aspect ratio of the output image\n        - `negative_prompt` - keywords of what you **do not** wish to see in the output image\n        - `seed` - the randomness seed to use for the generation\n        - `output_format` - the the format of the output image\n        > **Note:** for the full list of optional parameters, please see the request schema below.\n        ### Output\n        The resolution of the generated image will be 1 megapixel. The default resolution is 1024x1024.\n        ### Credits\n        The Ultra service uses 8 credits per successful result. You will not be charged for failed results.\n      x-codeSamples:\n        - lang: python\n          label: Python\n          source: |-\n            import requests\n            response = requests.post(\n                f\"https://api.stability.ai/v2beta/stable-image/generate/ultra\",\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\": \"webp\",\n                },\n            )\n            if response.status_code == 200:\n                with open(\"./lighthouse.webp\", 'wb') as file:\n                    file.write(response.content)\n            else:\n                raise Exception(str(response.json()))\n        - lang: javascript\n          label: JavaScript\n          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}\"\n        - lang: terminal\n          label: cURL\n          source: >-\n            curl -f -sS \"https://api.stability.ai/v2beta/stable-image/generate/ultra\" \\\n              -H \"authorization: Bearer sk-MYAPIKEY\" \\\n              -H \"accept: image/*\" \\\n              -F prompt=\"Lighthouse on a cliff overlooking the ocean\" \\\n              -F output_format=\"webp\" \\\n              -o \"./lighthouse.webp\"\n      parameters:\n        - schema:\n            type: string\n            description: >-\n              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.\n            minLength: 1\n          required: true\n          name: authorization\n          in: header\n        - schema:\n            type: string\n            minLength: 1\n            description: >-\n              The content type of the request body. Do not manually specify this header; your HTTP client library will automatically include the appropriate boundary parameter.\n            example: multipart/form-data\n          required: true\n          name: content-type\n          in: header\n        - schema:\n            type: string\n            default: image/*\n            description: >-\n              Specify `image/*` to receive the bytes of the image directly. Otherwise specify `application/json` to receive the image as base64 encoded JSON.\n            enum:\n              - image/*\n              - application/json\n          required: false\n          name: accept\n          in: header\n        - schema:\n            $ref: \"#/components/schemas/StabilityStabilityClientID\"\n          required: false\n          name: stability-client-id\n          in: header\n        - schema:\n            $ref: \"#/components/schemas/StabilityStabilityClientUserID\"\n          required: false\n          name: stability-client-user-id\n          in: header\n        - schema:\n            $ref: \"#/components/schemas/StabilityStabilityClientVersion\"\n          required: false\n          name: stability-client-version\n          in: header\n      requestBody:\n        content:\n          multipart/form-data:\n            schema:\n              type: object\n              properties:\n                prompt:\n                  type: string\n                  minLength: 1\n                  maxLength: 10000\n                  description: >-\n                    What you wish to see in the output image. A strong, descriptive prompt that clearly defines\n                    elements, colors, and subjects will lead to better results.\n                    To control the weight of a given word use the format `(word:weight)`,\n                    where `word` is the word you'd like to control the weight of and `weight`\n                    is a value between 0 and 1. For example: `The sky was a crisp (blue:0.3) and (green:0.8)`\n                    would convey a sky that was blue and green, but more green than blue.\n                negative_prompt:\n                  type: string\n                  maxLength: 10000\n                  description: >-\n                    A blurb of text describing what you **do not** wish to see in the output image.\n                    This is an advanced feature.\n                aspect_ratio:\n                  type: string\n                  enum:\n                    - \"21:9\"\n                    - \"16:9\"\n                    - \"3:2\"\n                    - \"5:4\"\n                    - \"1:1\"\n                    - \"4:5\"\n                    - \"2:3\"\n                    - \"9:16\"\n                    - \"9:21\"\n                  default: \"1:1\"\n                  description: Controls the aspect ratio of the generated image.\n                seed:\n                  type: number\n                  minimum: 0\n                  maximum: 4294967294\n                  default: 0\n                  description: >-\n                    A specific value that is used to guide the 'randomness' of the generation. (Omit this parameter or pass `0` to use a random seed.)\n                output_format:\n                  type: string\n                  enum:\n                    - jpeg\n                    - png\n                    - webp\n                  default: png\n                  description: Dictates the `content-type` of the generated image.\n                image:\n                  type: string\n                  description: >-\n                    The image to use as the starting point for the generation.\n                    > **Important:** The `strength` parameter is required when `image` is provided.\n                    Supported Formats:\n                    - jpeg\n                    - png\n                    - webp\n                    Validation Rules:\n                    - Width must be between 64 and 16,384 pixels\n                    - Height must be between 64 and 16,384 pixels\n                    - Total pixel count must be at least 4,096 pixels\n                  format: binary\n                  example: ./some/image.png\n                style_preset:\n                  type: string\n                  enum:\n                    - enhance\n                    - anime\n                    - photographic\n                    - digital-art\n                    - comic-book\n                    - fantasy-art\n                    - line-art\n                    - analog-film\n                    - neon-punk\n                    - isometric\n                    - low-poly\n                    - origami\n                    - modeling-compound\n                    - cinematic\n                    - 3d-model\n                    - pixel-art\n                    - tile-texture\n                  description: Guides the image model towards a particular style.\n                strength:\n                  type: number\n                  minimum: 0\n                  maximum: 1\n                  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.\"\n              required:\n                - prompt\n      responses:\n        \"200\":\n          description: Generation was successful.\n          headers:\n            x-request-id:\n              description: A unique identifier for this request.\n              schema:\n                type: string\n            content-type:\n              description: |-\n                The format of the generated image.\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`.\n              examples:\n                jpeg:\n                  description: raw bytes\n                  value: image/jpeg\n                jpegJSON:\n                  description: base64 encoded\n                  value: application/json; type=image/jpeg\n                png:\n                  description: raw bytes\n                  value: image/png\n                pngJSON:\n                  description: base64 encoded\n                  value: application/json; type=image/png\n                webp:\n                  description: raw bytes\n                  value: image/webp\n                webpJSON:\n                  description: base64 encoded\n                  value: application/json; type=image/webp\n              schema:\n                type: string\n            finish-reason:\n              schema:\n                type: string\n                enum:\n                  - SUCCESS\n                  - CONTENT_FILTERED\n              description: >-\n                Indicates the reason the generation finished.\n                - `SUCCESS` = successful generation.\n                - `CONTENT_FILTERED` = successful generation, however the output violated our content moderation\n                policy and has been blurred as a result.\n                > **NOTE:** This header is absent on JSON encoded responses because it is present in the body as `finish_reason`.\n            seed:\n              description: >-\n                The seed used as random noise for this generation.\n                > **NOTE:** This header is absent on JSON encoded responses because it is present in the body as `seed`.\n              example: \"343940597\"\n              schema:\n                type: string\n          content:\n            image/jpeg:\n              schema:\n                type: string\n                description: |-\n                  The bytes of the generated image.\n                  The `finish-reason` and `seed` will be present as headers.\n                format: binary\n              example: The bytes of the generated jpeg\n            application/json; type=image/jpeg:\n              schema:\n                type: object\n                properties:\n                  image:\n                    type: string\n                    description: \"The generated image, encoded to base64.\"\n                    example: AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1...\n                  seed:\n                    type: number\n                    minimum: 0\n                    maximum: 4294967294\n                    default: 0\n                    description: The seed used as random noise for this generation.\n                    example: 343940597\n                  finish_reason:\n                    type: string\n                    enum:\n                      - SUCCESS\n                      - CONTENT_FILTERED\n                    description: >-\n                      The reason the generation finished.\n                      - `SUCCESS` = successful generation.\n                      - `CONTENT_FILTERED` = successful generation, however the output violated our content moderation\n                      policy and has been blurred as a result.\n                    example: SUCCESS\n                required:\n                  - image\n                  - finish_reason\n            image/png:\n              schema:\n                type: string\n                description: |-\n                  The bytes of the generated image.\n                  The `finish-reason` and `seed` will be present as headers.\n                format: binary\n              example: The bytes of the generated png\n            application/json; type=image/png:\n              schema:\n                type: object\n                properties:\n                  image:\n                    type: string\n                    description: \"The generated image, encoded to base64.\"\n                    example: AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1...\n                  seed:\n                    type: number\n                    minimum: 0\n                    maximum: 4294967294\n                    default: 0\n                    description: The seed used as random noise for this generation.\n                    example: 343940597\n                  finish_reason:\n                    type: string\n                    enum:\n                      - SUCCESS\n                      - CONTENT_FILTERED\n                    description: >-\n                      The reason the generation finished.\n                      - `SUCCESS` = successful generation.\n                      - `CONTENT_FILTERED` = successful generation, however the output violated our content moderation\n                      policy and has been blurred as a result.\n                    example: SUCCESS\n                required:\n                  - image\n                  - finish_reason\n            image/webp:\n              schema:\n                type: string\n                description: |-\n                  The bytes of the generated image.\n                  The `finish-reason` and `seed` will be present as headers.\n                format: binary\n              example: The bytes of the generated webp\n            application/json; type=image/webp:\n              schema:\n                type: object\n                properties:\n                  image:\n                    type: string\n                    description: \"The generated image, encoded to base64.\"\n                    example: AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1...\n                  seed:\n                    type: number\n                    minimum: 0\n                    maximum: 4294967294\n                    default: 0\n                    description: The seed used as random noise for this generation.\n                    example: 343940597\n                  finish_reason:\n                    type: string\n                    enum:\n                      - SUCCESS\n                      - CONTENT_FILTERED\n                    description: >-\n                      The reason the generation finished.\n                      - `SUCCESS` = successful generation.\n                      - `CONTENT_FILTERED` = successful generation, however the output violated our content moderation\n                      policy and has been blurred as a result.\n                    example: SUCCESS\n                required:\n                  - image\n                  - finish_reason\n        \"400\":\n          description: \"Invalid parameter(s), see the `errors` field for details.\"\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  id:\n                    type: string\n                    minLength: 1\n                    description: >-\n                      A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n                      you file, as it will greatly assist us in diagnosing the root cause of the problem.\n                    example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n                  name:\n                    type: string\n                    minLength: 1\n                    description: >-\n                      Short-hand name for an error, useful for discriminating between errors with the same status code.\n                    example: bad_request\n                  errors:\n                    type: array\n                    items:\n                      type: string\n                    minItems: 1\n                    description: One or more error messages indicating what went wrong.\n                    example:\n                      - \"some-field: is required\"\n                required:\n                  - id\n                  - name\n                  - errors\n        \"403\":\n          description: Your request was flagged by our content moderation system.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityContentModerationResponse\"\n        \"413\":\n          description: Your request was larger than 10MiB.\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  id:\n                    type: string\n                    minLength: 1\n                    description: >-\n                      A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n                      you file, as it will greatly assist us in diagnosing the root cause of the problem.\n                    example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n                  name:\n                    type: string\n                    minLength: 1\n                    description: >-\n                      Short-hand name for an error, useful for discriminating between errors with the same status code.\n                    example: bad_request\n                  errors:\n                    type: array\n                    items:\n                      type: string\n                    minItems: 1\n                    description: One or more error messages indicating what went wrong.\n                    example:\n                      - \"some-field: is required\"\n                required:\n                  - id\n                  - name\n                  - errors\n                example:\n                  id: 4212a4b66fbe1cedca4bf2133d35dca5\n                  name: payload_too_large\n                  errors:\n                    - \"body: payloads cannot be larger than 10MiB in size\"\n        \"422\":\n          description: >-\n            Your request was well-formed, but rejected. See the `errors` field for details.\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  id:\n                    type: string\n                    minLength: 1\n                    description: >-\n                      A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n                      you file, as it will greatly assist us in diagnosing the root cause of the problem.\n                    example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n                  name:\n                    type: string\n                    minLength: 1\n                    description: >-\n                      Short-hand name for an error, useful for discriminating between errors with the same status code.\n                    example: bad_request\n                  errors:\n                    type: array\n                    items:\n                      type: string\n                    minItems: 1\n                    description: One or more error messages indicating what went wrong.\n                    example:\n                      - \"some-field: is required\"\n                required:\n                  - id\n                  - name\n                  - errors\n              examples:\n                Invalid Language:\n                  value:\n                    id: ff54b236a3acdde1522cb1ba641c43ed\n                    name: invalid_language\n                    errors:\n                      - English is the only supported language for this service.\n                Public Figure Detected:\n                  value:\n                    id: ff54b236a3acdde1522cb1ba641c43ed\n                    name: public_figure\n                    errors:\n                      - >-\n                        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.\n        \"429\":\n          description: You have made more than 150 requests in 10 seconds.\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  id:\n                    type: string\n                    minLength: 1\n                    description: >-\n                      A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n                      you file, as it will greatly assist us in diagnosing the root cause of the problem.\n                    example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n                  name:\n                    type: string\n                    minLength: 1\n                    description: >-\n                      Short-hand name for an error, useful for discriminating between errors with the same status code.\n                    example: bad_request\n                  errors:\n                    type: array\n                    items:\n                      type: string\n                    minItems: 1\n                    description: One or more error messages indicating what went wrong.\n                    example:\n                      - \"some-field: is required\"\n                required:\n                  - id\n                  - name\n                  - errors\n                example:\n                  id: rate_limit_exceeded\n                  name: rate_limit_exceeded\n                  errors:\n                    - >-\n                      You have exceeded the rate limit of 150 requests within a 10 second period, and have been timed out for 60 seconds.\n        \"500\":\n          description: >-\n            An internal error occurred. If the problem persists [contact support](https://kb.stability.ai/knowledge-base/kb-tickets/new).\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  id:\n                    type: string\n                    minLength: 1\n                    description: >-\n                      A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n                      you file, as it will greatly assist us in diagnosing the root cause of the problem.\n                    example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n                  name:\n                    type: string\n                    minLength: 1\n                    description: >-\n                      Short-hand name for an error, useful for discriminating between errors with the same status code.\n                    example: bad_request\n                  errors:\n                    type: array\n                    items:\n                      type: string\n                    minItems: 1\n                    description: One or more error messages indicating what went wrong.\n                    example:\n                      - \"some-field: is required\"\n                required:\n                  - id\n                  - name\n                  - errors\n                example:\n                  id: 2a1b2d4eafe2bc6ab4cd4d5c6133f513\n                  name: internal_error\n                  errors:\n                    - >-\n                      An unexpected server error has occurred, please try again later.\n  /proxy/stability/v2beta/stable-image/generate/sd3:\n    post:\n      operationId: StabilityImageGenrationSD3\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      summary: Stable Diffusion 3.5\n      description:\n        \"Generate using Stable Diffusion 3.5 models, Stability AI latest\\\n        \\ base model:\\n\\n- **Stable Diffusion 3.5 Large**: At 8 billion parameters,\\\n        \\ with superior quality and\\n\\n\\n\\n  prompt adherence, this base model is\\\n        \\ the most powerful in the Stable Diffusion\\n  family. This model is ideal\\\n        \\ for professional use cases at 1 megapixel resolution.\\n\\n- **Stable Diffusion\\\n        \\ 3.5 Large Turbo**: A distilled version of Stable Diffusion 3.5 Large.\\n\\n\\\n        \\n\\n  SD3.5 Large Turbo generates high-quality images with exceptional prompt\\\n        \\ adherence\\n  in just 4 steps, making it considerably faster than Stable\\\n        \\ Diffusion 3.5 Large.\\n\\n- **Stable Diffusion 3.5 Medium**: With 2.5 billion\\\n        \\ parameters, the model delivers an\\n\\n\\n\\n  optimal balance between prompt\\\n        \\ accuracy and image quality, making it an efficient\\n  choice for fast high-performance\\\n        \\ image generation.\\n\\nRead more about the model capabilities [here](https://stability.ai/news/introducing-stable-diffusion-3-5).\\n\\\n        \\nAs of April 17, 2025, we have deprecated the Stable Diffusion 3.0 APIs and\\\n        \\ will be automatically\\nre-routing calls to Stable Diffusion 3.0 models to\\\n        \\ Stable Diffusion 3.5 APIs at no extra cost.\\nYou can read more in the [release\\\n        \\ notes](/docs/release-notes#api-deprecation-notice).\\n\\n### Try it out\\n\\\n        Grab your [API key](https://platform.stability.ai/account/keys) and head over\\\n        \\ 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        \\n### How to use\\nPlease invoke this endpoint with a `POST` request.\\n\\nThe\\\n        \\ headers of the request must include an API key in the `authorization` field.\\\n        \\ The body of the request must be\\n`multipart/form-data`.  The accept header\\\n        \\ should be set to one of the following:\\n- `image/*` to receive the image\\\n        \\ in the format specified by the `output_format` parameter.\\n- `application/json`\\\n        \\ to receive the image encoded as base64 in a JSON response.\\n\\n#### **Generating\\\n        \\ with a prompt**\\nCommonly referred to as **text-to-image**, this mode generates\\\n        \\ an image from text alone. While the only required\\nparameter is the `prompt`,\\\n        \\ it also supports an `aspect_ratio` parameter which can be used to control\\\n        \\ the\\naspect ratio of the generated image.\\n\\n#### **Generating with a prompt\\\n        \\ *and* an image**\\nCommonly referred to as **image-to-image**, this mode\\\n        \\ also generates an image from text but uses an existing image as the\\nstarting\\\n        \\ point. The required parameters are:\\n- `prompt` - text to generate the image\\\n        \\ from\\n- `image` - the image to use as the starting point for the generation\\n\\\n        - `strength` - controls how much influence the `image` parameter has on the\\\n        \\ output image\\n- `mode` - must be set to `image-to-image`\\n\\n> **Note:**\\\n        \\ maximum request size is 10MiB.\\n\\n#### **Optional Parameters:**\\nBoth modes\\\n        \\ support the following optional parameters:\\n- `model` - the model to use\\\n        \\ (SD3.5 Large, SD3.5 Large Turbo, SD3.5 Medium)\\n- `output_format` - the\\\n        \\ the format of the output image\\n- `seed` - the randomness seed to use for\\\n        \\ the generation\\n- `negative_prompt` - keywords of what you **do not** wish\\\n        \\ to see in the output image\\n- `cfg_scale` - controls how strictly the diffusion\\\n        \\ process adheres to the prompt text\\n- `style_preset` - guides the image\\\n        \\ model towards a particular style\\n\\n> **Note:** for more details about these\\\n        \\ parameters please see the request schema below.\\n\\n### Output\\nThe resolution\\\n        \\ of the generated image will be 1MP. The default resolution is 1024x1024.\\n\\\n        \\n### Credits\\n- **SD 3.5 Large**: Flat rate of 6.5 credits per successful\\\n        \\ generation.\\n- **SD 3.5 Large Turbo**: Flat rate of 4 credits per successful\\\n        \\ generation.\\n- **SD 3.5 Medium**: Flat rate of 3.5 credits per successful\\\n        \\ generation.\\n\\nAs always, you will not be charged for failed generations.\"\n      x-codeSamples:\n        - lang: python\n          label: Python\n          source:\n            \"import requests\\n\\nresponse = requests.post(\\n    f\\\"https://api.stability.ai/v2beta/stable-image/generate/sd3\\\"\\\n            ,\\n    headers={\\n        \\\"authorization\\\": f\\\"Bearer sk-MYAPIKEY\\\",\\n\\\n            \\        \\\"accept\\\": \\\"image/*\\\"\\n    },\\n    files={\\\"none\\\": ''},\\n  \\\n            \\  data={\\n        \\\"prompt\\\": \\\"Lighthouse on a cliff overlooking the ocean\\\"\\\n            ,\\n        \\\"output_format\\\": \\\"jpeg\\\",\\n    },\\n)\\n\\nif response.status_code\\\n            \\ == 200:\\n    with open(\\\"./lighthouse.jpeg\\\", 'wb') as file:\\n       \\\n            \\ file.write(response.content)\\nelse:\\n    raise Exception(str(response.json()))\"\n        - lang: javascript\n          label: JavaScript\n          source:\n            \"import fs from \\\"node:fs\\\";\\nimport axios from \\\"axios\\\";\\nimport\\\n            \\ FormData from \\\"form-data\\\";\\n\\nconst payload = {\\n  prompt: \\\"Lighthouse\\\n            \\ on a cliff overlooking the ocean\\\",\\n  output_format: \\\"jpeg\\\"\\n};\\n\\n\\\n            const response = await axios.postForm(\\n  `https://api.stability.ai/v2beta/stable-image/generate/sd3`,\\n\\\n            \\  axios.toFormData(payload, new FormData()),\\n  {\\n    validateStatus:\\\n            \\ undefined,\\n    responseType: \\\"arraybuffer\\\",\\n    headers: { \\n    \\\n            \\  Authorization: `Bearer sk-MYAPIKEY`, \\n      Accept: \\\"image/*\\\" \\n \\\n            \\   },\\n  },\\n);\\n\\nif(response.status === 200) {\\n  fs.writeFileSync(\\\"\\\n            ./lighthouse.jpeg\\\", Buffer.from(response.data));\\n} else {\\n  throw new\\\n            \\ Error(`${response.status}: ${response.data.toString()}`);\\n}\"\n        - lang: terminal\n          label: cURL\n          source:\n            \"curl -f -sS \\\"https://api.stability.ai/v2beta/stable-image/generate/sd3\\\"\\\n            \\ \\\\\\n\\n\\n\\n\\n\\n\\n  -H \\\"authorization: Bearer sk-MYAPIKEY\\\" \\\\\\n  -H \\\"\\\n            accept: image/*\\\" \\\\\\n  -F prompt=\\\"Lighthouse on a cliff overlooking the\\\n            \\ ocean\\\" \\\\\\n  -F output_format=\\\"jpeg\\\" \\\\\\n  -o \\\"./lighthouse.jpeg\\\"\"\n      parameters:\n        - schema:\n            type: string\n            description:\n              Your [Stability API key](https://platform.stability.ai/account/keys),\n              used to authenticate your requests. Although you may have multiple keys\n              in your account, you should use the same key for all requests to this\n              API.\n            minLength: 1\n          required: true\n          name: authorization\n          in: header\n        - schema:\n            type: string\n            minLength: 1\n            description:\n              The content type of the request body. Do not manually specify\n              this header; your HTTP client library will automatically include the appropriate\n              boundary parameter.\n            example: multipart/form-data\n          required: true\n          name: content-type\n          in: header\n        - schema:\n            type: string\n            default: image/*\n            description:\n              Specify `image/*` to receive the bytes of the image directly.\n              Otherwise specify `application/json` to receive the image as base64 encoded\n              JSON.\n            enum:\n              - image/*\n              - application/json\n          required: false\n          name: accept\n          in: header\n        - schema:\n            $ref: \"#/components/schemas/StabilityStabilityClientID\"\n          required: false\n          name: stability-client-id\n          in: header\n        - schema:\n            $ref: \"#/components/schemas/StabilityStabilityClientUserID\"\n          required: false\n          name: stability-client-user-id\n          in: header\n        - schema:\n            $ref: \"#/components/schemas/StabilityStabilityClientVersion\"\n          required: false\n          name: stability-client-version\n          in: header\n      requestBody:\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/StabilityImageGenerationSD3_Request\"\n      responses:\n        \"200\":\n          description: Generation was successful.\n          headers:\n            x-request-id:\n              description: A unique identifier for this request.\n              schema:\n                type: string\n            content-type:\n              description:\n                \"The format of the generated image.\\n\\n To receive the\\\n                \\ bytes of the image directly, specify `image/*` in the accept header.\\\n                \\ To receive the bytes base64 encoded inside of a JSON payload, specify\\\n                \\ `application/json`.\"\n              examples:\n                png:\n                  description: raw bytes\n                  value: image/png\n                pngJSON:\n                  description: base64 encoded\n                  value: application/json; type=image/png\n                jpeg:\n                  description: raw bytes\n                  value: image/jpeg\n                jpegJSON:\n                  description: base64 encoded\n                  value: application/json; type=image/jpeg\n              schema:\n                type: string\n            finish-reason:\n              schema:\n                type: string\n                enum:\n                  - SUCCESS\n                  - CONTENT_FILTERED\n              description: \"Indicates the reason the generation finished.\n\n\n                - `SUCCESS` = successful generation.\n\n                - `CONTENT_FILTERED` = successful generation, however the output violated\n                our content moderation\n\n                policy and has been blurred as a result.\n\n\n                > **NOTE:** This header is absent on JSON encoded responses because\n                it is present in the body as `finish_reason`.\"\n            seed:\n              description: \"The seed used as random noise for this generation.\n\n\n                > **NOTE:** This header is absent on JSON encoded responses because\n                it is present in the body as `seed`.\"\n              example: \"343940597\"\n              schema:\n                type: string\n          content:\n            image/png:\n              schema:\n                type: string\n                description: \"The bytes of the generated image.\n\n\n                  The `finish-reason` and `seed` will be present as headers.\"\n                format: binary\n              example: The bytes of the generated png\n            application/json; type=image/png:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationSD3_Response_200\"\n            image/jpeg:\n              schema:\n                type: string\n                description: \"The bytes of the generated image.\n\n\n                  The `finish-reason` and `seed` will be present as headers.\"\n                format: binary\n              example: The bytes of the generated jpeg\n            application/json; type=image/jpeg:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationSD3_Response_200\"\n        \"400\":\n          description: Invalid parameter(s), see the `errors` field for details.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationSD3_Response_400\"\n        \"403\":\n          description: Your request was flagged by our content moderation system.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityContentModerationResponse\"\n        \"413\":\n          description: Your request was larger than 10MiB.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationSD3_Response_413\"\n        \"422\":\n          description:\n            Your request was well-formed, but rejected. See the `errors`\n            field for details.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationSD3_Response_422\"\n              examples:\n                Invalid Language:\n                  value:\n                    id: ff54b236a3acdde1522cb1ba641c43ed\n                    name: invalid_language\n                    errors:\n                      - English is the only supported language for this service.\n                Public Figure Detected:\n                  value:\n                    id: ff54b236a3acdde1522cb1ba641c43ed\n                    name: public_figure\n                    errors:\n                      - Our system detected the likeness of a public figure in your\n                        image. To comply with our guidelines, this request cannot be\n                        processed. Please upload a different image.\n        \"429\":\n          description: You have made more than 150 requests in 10 seconds.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationSD3_Response_429\"\n        \"500\":\n          description:\n            An internal error occurred. If the problem persists [contact\n            support](https://kb.stability.ai/knowledge-base/kb-tickets/new).\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationSD3_Response_500\"\n  /proxy/stability/v2beta/stable-image/upscale/conservative:\n    post:\n      tags:\n        - API Nodes\n        - Released\n      x-excluded: true\n      summary: Conservative\n      description:\n        \"Takes images between 64x64 and 1 megapixel and upscales them all\\\n        \\ the way to 4K resolution. Put more generally, it can upscale images ~20-40x\\\n        \\ times while preserving all aspects. Conservative Upscale minimizes alterations\\\n        \\ to the image and should not be used to reimagine an image.\\n\\n### Try it\\\n        \\ out\\nGrab your [API key](https://platform.stability.ai/account/keys) and\\\n        \\ 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        \\n### How to use\\n\\nPlease invoke this endpoint with a `POST` request.\\n\\n\\\n        The headers of the request must include an API key in the `authorization`\\\n        \\ field. The body of the request must be\\n`multipart/form-data`, and the `accept`\\\n        \\ header should be set to one of the following:\\n\\n\\n\\n  - `image/*` to receive\\\n        \\ the image in the format specified by the `output_format` parameter.\\n  -\\\n        \\ `application/json` to receive the image encoded as base64 in a JSON response.\\n\\\n        \\nThe body of the request must include:\\n- `image`\\n- `prompt`\\n\\nOptionally,\\\n        \\ the body of the request may also include:\\n- `negative_prompt`\\n- `seed`\\n\\\n        - `output_format`\\n- `creativity`\\n\\n> **Note:** for more details about these\\\n        \\ parameters please see the request schema below.\\n\\n### Output\\nThe resolution\\\n        \\ of the generated image will be 4 megapixels.\\n\\n### Credits\\nFlat rate of\\\n        \\ 25 credits per successful generation.  You will not be charged for failed\\\n        \\ generations.\"\n      x-codeSamples:\n        - lang: python\n          label: Python\n          source:\n            \"import requests\\n\\nresponse = requests.post(\\n    f\\\"https://api.stability.ai/v2beta/stable-image/upscale/conservative\\\"\\\n            ,\\n    headers={\\n        \\\"authorization\\\": f\\\"Bearer sk-MYAPIKEY\\\",\\n\\\n            \\        \\\"accept\\\": \\\"image/*\\\"\\n    },\\n    files={\\n        \\\"image\\\"\\\n            : open(\\\"./low-res-flower.jpg\\\", \\\"rb\\\"),\\n    },\\n    data={\\n        \\\"\\\n            prompt\\\": \\\"a flower\\\",\\n        \\\"output_format\\\": \\\"webp\\\",\\n    },\\n\\\n            )\\n\\nif response.status_code == 200:\\n    with open(\\\"./flower.webp\\\", 'wb')\\\n            \\ as file:\\n        file.write(response.content)\\nelse:\\n    raise Exception(str(response.json()))\"\n        - lang: javascript\n          label: JavaScript\n          source:\n            \"import fs from \\\"node:fs\\\";\\nimport axios from \\\"axios\\\";\\nimport\\\n            \\ FormData from \\\"form-data\\\";\\n\\nconst payload = {\\n  image: fs.createReadStream(\\\"\\\n            ./low-res-flower.jpg\\\"),\\n  prompt: \\\"a flower\\\",\\n  output_format: \\\"webp\\\"\\\n            \\n};\\n\\nconst response = await axios.postForm(\\n  `https://api.stability.ai/v2beta/stable-image/upscale/conservative`,\\n\\\n            \\  axios.toFormData(payload, new FormData()),\\n  {\\n    validateStatus:\\\n            \\ undefined,\\n    responseType: \\\"arraybuffer\\\",\\n    headers: { \\n    \\\n            \\  Authorization: `Bearer sk-MYAPIKEY`, \\n      Accept: \\\"image/*\\\" \\n \\\n            \\   },\\n  },\\n);\\n\\nif(response.status === 200) {\\n  fs.writeFileSync(\\\"\\\n            ./flower.webp\\\", Buffer.from(response.data));\\n} else {\\n  throw new Error(`${response.status}:\\\n            \\ ${response.data.toString()}`);\\n}\"\n        - lang: terminal\n          label: cURL\n          source:\n            \"curl -f -sS \\\"https://api.stability.ai/v2beta/stable-image/upscale/conservative\\\"\\\n            \\ \\\\\\n\\n\\n\\n\\n\\n\\n  -H \\\"authorization: Bearer sk-MYAPIKEY\\\" \\\\\\n  -H \\\"\\\n            accept: image/*\\\" \\\\\\n  -F image=@\\\"./low-res-flower.jpg\\\" \\\\\\n  -F prompt=\\\"\\\n            a flower\\\" \\\\\\n  -F output_format=\\\"webp\\\" \\\\\\n  -o \\\"./flower.webp\\\"\"\n      parameters:\n        - schema:\n            type: string\n            description:\n              Your [Stability API key](https://platform.stability.ai/account/keys),\n              used to authenticate your requests. Although you may have multiple keys\n              in your account, you should use the same key for all requests to this\n              API.\n            minLength: 1\n          required: true\n          name: authorization\n          in: header\n        - schema:\n            type: string\n            minLength: 1\n            description:\n              The content type of the request body. Do not manually specify\n              this header; your HTTP client library will automatically include the appropriate\n              boundary parameter.\n            example: multipart/form-data\n          required: true\n          name: content-type\n          in: header\n        - schema:\n            type: string\n            default: image/*\n            description:\n              Specify `image/*` to receive the bytes of the image directly.\n              Otherwise specify `application/json` to receive the image as base64 encoded\n              JSON.\n            enum:\n              - image/*\n              - application/json\n          required: false\n          name: accept\n          in: header\n        - schema:\n            $ref: \"#/components/schemas/StabilityStabilityClientID\"\n          required: false\n          name: stability-client-id\n          in: header\n        - schema:\n            $ref: \"#/components/schemas/StabilityStabilityClientUserID\"\n          required: false\n          name: stability-client-user-id\n          in: header\n        - schema:\n            $ref: \"#/components/schemas/StabilityStabilityClientVersion\"\n          required: false\n          name: stability-client-version\n          in: header\n      requestBody:\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/StabilityImageGenrationUpscaleConservative_Request\"\n      responses:\n        \"200\":\n          description: Upscale was successful.\n          headers:\n            x-request-id:\n              description: A unique identifier for this request.\n              schema:\n                type: string\n            content-type:\n              description:\n                \"The format of the generated image.\\n\\n To receive the\\\n                \\ bytes of the image directly, specify `image/*` in the accept header.\\\n                \\ To receive the bytes base64 encoded inside of a JSON payload, specify\\\n                \\ `application/json`.\"\n              examples:\n                jpeg:\n                  description: raw bytes\n                  value: image/jpeg\n                jpegJSON:\n                  description: base64 encoded\n                  value: application/json; type=image/jpeg\n                png:\n                  description: raw bytes\n                  value: image/png\n                pngJSON:\n                  description: base64 encoded\n                  value: application/json; type=image/png\n                webp:\n                  description: raw bytes\n                  value: image/webp\n                webpJSON:\n                  description: base64 encoded\n                  value: application/json; type=image/webp\n              schema:\n                type: string\n            finish-reason:\n              schema:\n                type: string\n                enum:\n                  - SUCCESS\n                  - CONTENT_FILTERED\n              description: \"Indicates the reason the generation finished.\n\n\n                - `SUCCESS` = successful generation.\n\n                - `CONTENT_FILTERED` = successful generation, however the output violated\n                our content moderation\n\n                policy and has been blurred as a result.\n\n\n                > **NOTE:** This header is absent on JSON encoded responses because\n                it is present in the body as `finish_reason`.\"\n            seed:\n              description: \"The seed used as random noise for this generation.\n\n\n                > **NOTE:** This header is absent on JSON encoded responses because\n                it is present in the body as `seed`.\"\n              example: \"343940597\"\n              schema:\n                type: string\n          content:\n            image/jpeg:\n              schema:\n                type: string\n                description: \"The bytes of the generated image.\n\n\n                  The `finish-reason` and `seed` will be present as headers.\"\n                format: binary\n              example: The bytes of the generated jpeg\n            application/json; type=image/jpeg:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationUpscaleConservative_Response_200\"\n            image/png:\n              schema:\n                type: string\n                description: \"The bytes of the generated image.\n\n\n                  The `finish-reason` and `seed` will be present as headers.\"\n                format: binary\n              example: The bytes of the generated png\n            application/json; type=image/png:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationUpscaleConservative_Response_200\"\n            image/webp:\n              schema:\n                type: string\n                description: \"The bytes of the generated image.\n\n\n                  The `finish-reason` and `seed` will be present as headers.\"\n                format: binary\n              example: The bytes of the generated webp\n            application/json; type=image/webp:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationUpscaleConservative_Response_200\"\n        \"400\":\n          description: Invalid parameter(s), see the `errors` field for details.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationUpscaleConservative_Response_400\"\n        \"403\":\n          description: Your request was flagged by our content moderation system.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityContentModerationResponse\"\n        \"413\":\n          description: Your request was larger than 10MiB.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationUpscaleConservative_Response_413\"\n        \"422\":\n          description:\n            Your request was well-formed, but rejected. See the `errors`\n            field for details.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationUpscaleConservative_Response_422\"\n              examples:\n                Invalid Language:\n                  value:\n                    id: ff54b236a3acdde1522cb1ba641c43ed\n                    name: invalid_language\n                    errors:\n                      - English is the only supported language for this service.\n                Public Figure Detected:\n                  value:\n                    id: ff54b236a3acdde1522cb1ba641c43ed\n                    name: public_figure\n                    errors:\n                      - Our system detected the likeness of a public figure in your\n                        image. To comply with our guidelines, this request cannot be\n                        processed. Please upload a different image.\n        \"429\":\n          description: You have made more than 150 requests in 10 seconds.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationUpscaleConservative_Response_429\"\n        \"500\":\n          description:\n            An internal error occurred. If the problem persists [contact\n            support](https://kb.stability.ai/knowledge-base/kb-tickets/new).\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationUpscaleConservative_Response_500\"\n      operationId: StabilityImageGenrationUpscaleConservative\n  /proxy/stability/v2beta/stable-image/upscale/creative:\n    post:\n      tags:\n        - API Nodes\n        - Released\n      x-excluded: true\n      summary: Creative Upscale (async)\n      description:\n        \"Takes images between 64x64 and 1 megapixel and upscales them all\n        the way to **4K** resolution.  Put more\n\n        generally, it can upscale images ~20-40x times while preserving, and often\n        enhancing, quality.\n\n        Creative Upscale **works best on highly degraded images and is not for photos\n        of 1mp or above** as it performs\n\n        heavy reimagining (controlled by creativity scale).\n\n\n        ### Try it out\n\n        Grab your [API key](https://platform.stability.ai/account/keys) and head over\n        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)\n\n\n\n        ### How to use\n\n        Please invoke this endpoint with a `POST` request.\n\n\n        The headers of the request must include an API key in the `authorization`\n        field. The body of the request must be\n\n        `multipart/form-data`.\n\n\n        The body of the request should include:\n\n        - `image`\n\n        - `prompt`\n\n\n        The body may optionally include:\n\n        - `seed`\n\n        - `negative_prompt`\n\n        - `output_format`\n\n        - `creativity`\n\n        - `style_preset`\n\n\n        > **Note:** for more details about these parameters please see the request\n        schema below.\n\n\n        ### Results\n\n        After invoking this endpoint with the required parameters, use the `id` in\n        the response to poll for results at the\n\n        [results/{id} endpoint](#tag/Results/paths/~1v2beta~1results~1%7Bid%7D/get).  Rate-limiting\n        or other errors may occur if you poll more than once every 10 seconds.\n\n\n        ### Credits\n\n        Flat rate of 25 credits per successful generation.  You will not be charged\n        for failed generations.\"\n      x-codeSamples:\n        - lang: python\n          label: Python\n          source:\n            \"import requests\\n\\nresponse = requests.post(\\n    f\\\"https://api.stability.ai/v2beta/stable-image/upscale/creative\\\"\\\n            ,\\n    headers={\\n        \\\"authorization\\\": f\\\"Bearer sk-MYAPIKEY\\\",\\n\\\n            \\        \\\"accept\\\": \\\"image/*\\\"\\n    },\\n    files={\\n        \\\"image\\\"\\\n            : open(\\\"./kitten-in-space.png\\\", \\\"rb\\\")\\n    },\\n    data={\\n        \\\"\\\n            prompt\\\": \\\"cute fluffy white kitten floating in space, pastel colors\\\"\\\n            ,\\n        \\\"output_format\\\": \\\"webp\\\",\\n    },\\n)\\n\\nprint(\\\"Generation\\\n            \\ ID:\\\", response.json().get('id'))\"\n        - lang: javascript\n          label: JavaScript\n          source:\n            \"import fs from \\\"node:fs\\\";\\nimport axios from \\\"axios\\\";\\nimport\\\n            \\ FormData from \\\"form-data\\\";\\n\\nconst payload = {\\n  image: fs.createReadStream(\\\"\\\n            ./kitten-in-space.png\\\"),\\n  prompt: \\\"cute fluffy white kitten floating\\\n            \\ in space, pastel colors\\\",\\n  output_format: \\\"webp\\\"\\n};\\n\\nconst response\\\n            \\ = await axios.postForm(\\n  `https://api.stability.ai/v2beta/stable-image/upscale/creative`,\\n\\\n            \\  axios.toFormData(payload, new FormData()),\\n  {\\n    validateStatus:\\\n            \\ undefined,\\n    headers: { \\n      Authorization: `Bearer sk-MYAPIKEY`\\n\\\n            \\    },\\n  },\\n);\\n\\nconsole.log(\\\"Generation ID:\\\", response.data.id);\"\n        - lang: terminal\n          label: cURL\n          source:\n            \"curl -f -sS \\\"https://api.stability.ai/v2beta/stable-image/upscale/creative\\\"\\\n            \\ \\\\\\n\\n\\n\\n\\n\\n\\n  -H \\\"authorization: Bearer sk-MYAPIKEY\\\" \\\\\\n  -F image=@\\\"\\\n            ./kitten-in-rainforest.png\\\" \\\\\\n  -F prompt=\\\"cute fluffy white kitten\\\n            \\ sitting in a rainforest, pastel colors\\\" \\\\\\n  -F output_format=webp \\\\\\\n            \\n  -o \\\"./output.json\\\"\"\n      parameters:\n        - schema:\n            type: string\n            description:\n              Your [Stability API key](https://platform.stability.ai/account/keys),\n              used to authenticate your requests. Although you may have multiple keys\n              in your account, you should use the same key for all requests to this\n              API.\n            minLength: 1\n          required: true\n          name: authorization\n          in: header\n        - schema:\n            type: string\n            minLength: 1\n            description:\n              The content type of the request body. Do not manually specify\n              this header; your HTTP client library will automatically include the appropriate\n              boundary parameter.\n            example: multipart/form-data\n          required: true\n          name: content-type\n          in: header\n        - schema:\n            $ref: \"#/components/schemas/StabilityStabilityClientID\"\n          required: false\n          name: stability-client-id\n          in: header\n        - schema:\n            $ref: \"#/components/schemas/StabilityStabilityClientUserID\"\n          required: false\n          name: stability-client-user-id\n          in: header\n        - schema:\n            $ref: \"#/components/schemas/StabilityStabilityClientVersion\"\n          required: false\n          name: stability-client-version\n          in: header\n      requestBody:\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/StabilityImageGenrationUpscaleCreative_Request\"\n      responses:\n        \"200\":\n          description: Upscale was started.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationUpscaleCreative_Response_200\"\n        \"400\":\n          description: Invalid parameter(s), see the `errors` field for details.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationUpscaleCreative_Response_400\"\n        \"403\":\n          description: Your request was flagged by our content moderation system.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityContentModerationResponse\"\n        \"413\":\n          description: Your request was larger than 10MiB.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationUpscaleCreative_Response_413\"\n        \"422\":\n          description:\n            Your request was well-formed, but rejected. See the `errors`\n            field for details.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationUpscaleCreative_Response_422\"\n              examples:\n                Invalid Language:\n                  value:\n                    id: ff54b236a3acdde1522cb1ba641c43ed\n                    name: invalid_language\n                    errors:\n                      - English is the only supported language for this service.\n                Public Figure Detected:\n                  value:\n                    id: ff54b236a3acdde1522cb1ba641c43ed\n                    name: public_figure\n                    errors:\n                      - Our system detected the likeness of a public figure in your\n                        image. To comply with our guidelines, this request cannot be\n                        processed. Please upload a different image.\n        \"429\":\n          description: You have made more than 150 requests in 10 seconds.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationUpscaleCreative_Response_429\"\n        \"500\":\n          description:\n            An internal error occurred. If the problem persists [contact\n            support](https://kb.stability.ai/knowledge-base/kb-tickets/new).\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationUpscaleCreative_Response_500\"\n      operationId: StabilityImageGenrationUpscaleCreative\n  /proxy/stability/v2beta/stable-image/upscale/fast:\n    post:\n      tags:\n        - API Nodes\n        - Released\n      x-excluded: true\n      summary: Fast\n      description:\n        \"Our Fast Upscaler service enhances image resolution by 4x using\\\n        \\ predictive and generative AI. This lightweight and fast service (processing\\\n        \\ in ~1 second) is ideal for enhancing the quality of compressed images, making\\\n        \\ it suitable for social media posts and other applications.\\n\\n### Try it\\\n        \\ out\\nGrab your [API key](https://platform.stability.ai/account/keys) and\\\n        \\ 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        \\n### How to use\\n\\nPlease invoke this endpoint with a `POST` request.\\n\\n\\\n        The headers of the request must include an API key in the `authorization`\\\n        \\ field. The body of the request must be\\n`multipart/form-data`, and the `accept`\\\n        \\ header should be set to one of the following:\\n\\n\\n\\n  - `image/*` to receive\\\n        \\ the image in the format specified by the `output_format` parameter.\\n  -\\\n        \\ `application/json` to receive the image encoded as base64 in a JSON response.\\n\\\n        \\nThe body of the request must include:\\n- `image`\\n\\nOptionally, the body\\\n        \\ of the request may also include:\\n- `output_format`\\n\\n> **Note:** for more\\\n        \\ details about these parameters please see the request schema below.\\n\\n\\\n        ### Output\\nThe resolution of the generated image is 4 times that of the input\\\n        \\ image with a maximum size of 16 megapixels.\\n\\n### Credits\\nFlat rate of\\\n        \\ 1 credit per successful generation. You will not be charged for failed generations.\"\n      x-codeSamples:\n        - lang: python\n          label: Python\n          source:\n            \"import requests\\n\\nresponse = requests.post(\\n    f\\\"https://api.stability.ai/v2beta/stable-image/upscale/fast\\\"\\\n            ,\\n    headers={\\n        \\\"authorization\\\": f\\\"Bearer sk-MYAPIKEY\\\",\\n\\\n            \\        \\\"accept\\\": \\\"image/*\\\"\\n    },\\n    files={\\n        \\\"image\\\"\\\n            : open(\\\"./low-res-flower.jpg\\\", \\\"rb\\\"),\\n    },\\n    data={\\n        \\\"\\\n            output_format\\\": \\\"webp\\\",\\n    },\\n)\\n\\nif response.status_code == 200:\\n\\\n            \\    with open(\\\"./flower.webp\\\", 'wb') as file:\\n        file.write(response.content)\\n\\\n            else:\\n    raise Exception(str(response.json()))\"\n        - lang: javascript\n          label: JavaScript\n          source:\n            \"import fs from \\\"node:fs\\\";\\nimport axios from \\\"axios\\\";\\nimport\\\n            \\ FormData from \\\"form-data\\\";\\n\\nconst payload = {\\n  image: fs.createReadStream(\\\"\\\n            ./low-res-flower.jpg\\\"),\\n  output_format: \\\"webp\\\"\\n};\\n\\nconst response\\\n            \\ = await axios.postForm(\\n  `https://api.stability.ai/v2beta/stable-image/upscale/fast`,\\n\\\n            \\  axios.toFormData(payload, new FormData()),\\n  {\\n    validateStatus:\\\n            \\ undefined,\\n    responseType: \\\"arraybuffer\\\",\\n    headers: { \\n    \\\n            \\  Authorization: `Bearer sk-MYAPIKEY`, \\n      Accept: \\\"image/*\\\" \\n \\\n            \\   },\\n  },\\n);\\n\\nif(response.status === 200) {\\n  fs.writeFileSync(\\\"\\\n            ./flower.webp\\\", Buffer.from(response.data));\\n} else {\\n  throw new Error(`${response.status}:\\\n            \\ ${response.data.toString()}`);\\n}\"\n        - lang: terminal\n          label: cURL\n          source:\n            \"curl -f -sS \\\"https://api.stability.ai/v2beta/stable-image/upscale/fast\\\"\\\n            \\ \\\\\\n\\n\\n\\n\\n\\n\\n  -H \\\"authorization: Bearer sk-MYAPIKEY\\\" \\\\\\n  -H \\\"\\\n            accept: image/*\\\" \\\\\\n  -F image=@\\\"./low-res-flower.jpg\\\" \\\\\\n  -F output_format=\\\"\\\n            webp\\\" \\\\\\n  -o \\\"./flower.webp\\\"\"\n      parameters:\n        - schema:\n            type: string\n            description:\n              Your [Stability API key](https://platform.stability.ai/account/keys),\n              used to authenticate your requests. Although you may have multiple keys\n              in your account, you should use the same key for all requests to this\n              API.\n            minLength: 1\n          required: true\n          name: authorization\n          in: header\n        - schema:\n            type: string\n            minLength: 1\n            description:\n              The content type of the request body. Do not manually specify\n              this header; your HTTP client library will automatically include the appropriate\n              boundary parameter.\n            example: multipart/form-data\n          required: true\n          name: content-type\n          in: header\n        - schema:\n            type: string\n            default: image/*\n            description:\n              Specify `image/*` to receive the bytes of the image directly.\n              Otherwise specify `application/json` to receive the image as base64 encoded\n              JSON.\n            enum:\n              - image/*\n              - application/json\n          required: false\n          name: accept\n          in: header\n        - schema:\n            $ref: \"#/components/schemas/StabilityStabilityClientID\"\n          required: false\n          name: stability-client-id\n          in: header\n        - schema:\n            $ref: \"#/components/schemas/StabilityStabilityClientUserID\"\n          required: false\n          name: stability-client-user-id\n          in: header\n        - schema:\n            $ref: \"#/components/schemas/StabilityStabilityClientVersion\"\n          required: false\n          name: stability-client-version\n          in: header\n      requestBody:\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/StabilityImageGenrationUpscaleFast_Request\"\n      responses:\n        \"200\":\n          description: Upscale was successful.\n          headers:\n            x-request-id:\n              description: A unique identifier for this request.\n              schema:\n                type: string\n            content-type:\n              description:\n                \"The format of the generated image.\\n\\n To receive the\\\n                \\ bytes of the image directly, specify `image/*` in the accept header.\\\n                \\ To receive the bytes base64 encoded inside of a JSON payload, specify\\\n                \\ `application/json`.\"\n              examples:\n                jpeg:\n                  description: raw bytes\n                  value: image/jpeg\n                jpegJSON:\n                  description: base64 encoded\n                  value: application/json; type=image/jpeg\n                png:\n                  description: raw bytes\n                  value: image/png\n                pngJSON:\n                  description: base64 encoded\n                  value: application/json; type=image/png\n                webp:\n                  description: raw bytes\n                  value: image/webp\n                webpJSON:\n                  description: base64 encoded\n                  value: application/json; type=image/webp\n              schema:\n                type: string\n            finish-reason:\n              schema:\n                type: string\n                enum:\n                  - SUCCESS\n                  - CONTENT_FILTERED\n              description: \"Indicates the reason the generation finished.\n\n\n                - `SUCCESS` = successful generation.\n\n                - `CONTENT_FILTERED` = successful generation, however the output violated\n                our content moderation\n\n                policy and has been blurred as a result.\n\n\n                > **NOTE:** This header is absent on JSON encoded responses because\n                it is present in the body as `finish_reason`.\"\n            seed:\n              description: \"The seed used as random noise for this generation.\n\n\n                > **NOTE:** This header is absent on JSON encoded responses because\n                it is present in the body as `seed`.\"\n              example: \"343940597\"\n              schema:\n                type: string\n          content:\n            image/jpeg:\n              schema:\n                type: string\n                description: \"The bytes of the generated image.\n\n\n                  The `finish-reason` and `seed` will be present as headers.\"\n                format: binary\n              example: The bytes of the generated jpeg\n            application/json; type=image/jpeg:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationUpscaleFast_Response_200\"\n            image/png:\n              schema:\n                type: string\n                description: \"The bytes of the generated image.\n\n\n                  The `finish-reason` and `seed` will be present as headers.\"\n                format: binary\n              example: The bytes of the generated png\n            application/json; type=image/png:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationUpscaleFast_Response_200\"\n            image/webp:\n              schema:\n                type: string\n                description: \"The bytes of the generated image.\n\n\n                  The `finish-reason` and `seed` will be present as headers.\"\n                format: binary\n              example: The bytes of the generated webp\n            application/json; type=image/webp:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationUpscaleFast_Response_200\"\n        \"400\":\n          description: Invalid parameter(s), see the `errors` field for details.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationUpscaleFast_Response_400\"\n        \"403\":\n          description: Your request was flagged by our content moderation system.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityContentModerationResponse\"\n        \"413\":\n          description: Your request was larger than 10MiB.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationUpscaleFast_Response_413\"\n        \"422\":\n          description:\n            Your request was well-formed, but rejected. See the `errors`\n            field for details.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationUpscaleFast_Response_422\"\n              examples:\n                Invalid Language:\n                  value:\n                    id: ff54b236a3acdde1522cb1ba641c43ed\n                    name: invalid_language\n                    errors:\n                      - English is the only supported language for this service.\n                Public Figure Detected:\n                  value:\n                    id: ff54b236a3acdde1522cb1ba641c43ed\n                    name: public_figure\n                    errors:\n                      - Our system detected the likeness of a public figure in your\n                        image. To comply with our guidelines, this request cannot be\n                        processed. Please upload a different image.\n        \"429\":\n          description: You have made more than 150 requests in 10 seconds.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationUpscaleFast_Response_429\"\n        \"500\":\n          description:\n            An internal error occurred. If the problem persists [contact\n            support](https://kb.stability.ai/knowledge-base/kb-tickets/new).\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityImageGenrationUpscaleFast_Response_500\"\n      operationId: StabilityImageGenerationUpscaleFast\n  /proxy/stability/v2beta/results/{id}:\n    get:\n      summary: Get Result\n      description: Get the result of a generation\n      operationId: StabilityGetResult\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: The ID of the generation result to retrieve.\n        - name: Accept\n          in: header\n          required: false\n          schema:\n            type: string\n            default: image/*\n          description: Set to image/* to receive image bytes.\n      responses:\n        \"200\":\n          description: The generated image as JPEG bytes.\n          content:\n            image/jpeg:\n              schema:\n                type: string\n                format: binary\n            application/json; type=image/jpeg:\n              schema:\n                type: object\n                properties:\n                  image:\n                    type: string\n                    description: The generated image, encoded to base64.\n                    example: AAAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1...\n                  seed:\n                    type: number\n                    minimum: 0\n                    maximum: 4294967294\n                    default: 0\n                    description: The seed used as random noise for this generation.\n                    example: 343940597\n                  finish_reason:\n                    type: string\n                    enum: [SUCCESS, CONTENT_FILTERED]\n                    description: |-\n                      The reason the generation finished.\n\n                      - `SUCCESS` = successful generation.\n                      - `CONTENT_FILTERED` = successful generation, however the output violated our content moderation\n                        policy and has been blurred as a result.\n                    example: SUCCESS\n                required:\n                  - image\n                  - finish_reason\n\n            image/png:\n              schema:\n                type: string\n                description: |-\n                  The bytes of the generated image.\n\n                  The `finish-reason` and `seed` will be present as headers.\n                format: binary\n              example: The bytes of the generated png\n\n            application/json; type=image/png:\n              schema:\n                type: object\n                properties:\n                  image:\n                    type: string\n                    description: The generated image, encoded to base64.\n                    example: AAAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1...\n                  seed:\n                    type: number\n                    minimum: 0\n                    maximum: 4294967294\n                    default: 0\n                    description: The seed used as random noise for this generation.\n                    example: 343940597\n                  finish_reason:\n                    type: string\n                    enum: [SUCCESS, CONTENT_FILTERED]\n                    description: |-\n                      The reason the generation finished.\n\n                      - `SUCCESS` = successful generation.\n                      - `CONTENT_FILTERED` = successful generation, however the output violated our content moderation\n                        policy and has been blurred as a result.\n                    example: SUCCESS\n                required:\n                  - image\n                  - finish_reason\n\n            image/webp:\n              schema:\n                type: string\n                description: |-\n                  The bytes of the generated image.\n\n                  The `finish-reason` and `seed` will be present as headers.\n                format: binary\n              example: The bytes of the generated webp\n\n            application/json; type=image/webp:\n              schema:\n                type: object\n                properties:\n                  image:\n                    type: string\n                    description: The generated image, encoded to base64.\n                    example: AAAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1...\n                  seed:\n                    type: number\n                    minimum: 0\n                    maximum: 4294967294\n                    default: 0\n                    description: The seed used as random noise for this generation.\n                    example: 343940597\n                  finish_reason:\n                    type: string\n                    enum: [SUCCESS, CONTENT_FILTERED]\n                    description: |-\n                      The reason the generation finished.\n\n                      - `SUCCESS` = successful generation.\n                      - `CONTENT_FILTERED` = successful generation, however the output violated our content moderation\n                        policy and has been blurred as a result.\n                    example: SUCCESS\n                required:\n                  - image\n                  - finish_reason\n        \"202\":\n          description: The generation is still in progress.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityGetResultResponse_202\"\n        \"400\":\n          description: Invalid result ID.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityError\"\n        \"404\":\n          description: Result not found.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityError\"\n        \"500\":\n          description:\n            An internal error occurred. If the problem persists [contact\n            support](https://kb.stability.ai/knowledge-base/kb-tickets/new).\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StabilityError\"\n  /proxy/stability/v2beta/audio/stable-audio-2/text-to-audio:\n    post:\n      summary: Proxy request to Stable Audio 2.5 for text-to-audio generation\n      operationId: stableAudio25TextToAudio\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/StableAudio25TextToAudioRequest\"\n      responses:\n        \"200\":\n          description: Successful response from Stable Audio proxy\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StableAudio25AudioResponse\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/stability/v2beta/audio/stable-audio-2/audio-to-audio:\n    post:\n      summary: Proxy request to Stable Audio for audio-to-audio transformation\n      operationId: stableAudio25AudioToAudio\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/StableAudio25AudioToAudioRequest\"\n      responses:\n        \"200\":\n          description: Successful response from Stable Audio proxy\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StableAudio25AudioResponse\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/stability/v2beta/audio/stable-audio-2/inpaint:\n    post:\n      summary: Proxy request to Stable Audio 2.5 for audio inpainting\n      operationId: stableAudio25Inpaint\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/StableAudio25InpaintRequest\"\n      responses:\n        \"200\":\n          description: Successful response from Stable Audio proxy\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/StableAudio25AudioResponse\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/vertexai/gemini/{model}:\n    post:\n      summary: Generate content using a specified model.\n      operationId: GeminiGenerateContent\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: model\n          in: path\n          schema:\n            type: string\n          required: true\n          description: Full resource name of the model.\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/GeminiGenerateContentRequest\"\n      responses:\n        \"200\":\n          description: Generated content response.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/GeminiGenerateContentResponse\"\n        \"400\":\n          description: Bad Request\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden\n        \"404\":\n          description: Not Found\n        \"500\":\n          description: Internal Server Error\n  /proxy/vertexai/imagen/{model}:\n    parameters:\n      - name: model\n        in: path\n        required: true\n        schema:\n          type: string\n          enum:\n            - imagen-3.0-generate-002\n            - imagen-3.0-generate-001\n            - imagen-3.0-fast-generate-001\n            - imagegeneration@006\n            - imagegeneration@005\n            - imagegeneration@002\n        description: image generation model\n    post:\n      summary: Generate images from a text prompt\n      operationId: ImagenGenerateImages\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/ImagenGenerateImageRequest\"\n      responses:\n        \"200\":\n          description: Successful image generation\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ImagenGenerateImageResponse\"\n        \"4XX\":\n          description: Client error\n        \"5XX\":\n          description: Server error\n\n  /proxy/tripo/v2/openapi/task/{task_id}:\n    get:\n      summary: Get Task Status\n      operationId: tripoGetTask\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      parameters:\n        - name: task_id\n          in: path\n          required: true\n          schema:\n            type: string\n      responses:\n        \"200\":\n          description: Request successful\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  code:\n                    $ref: \"#/components/schemas/TripoResponseSuccessCode\"\n                  data:\n                    $ref: \"#/components/schemas/TripoTask\"\n                required:\n                  - code\n                  - data\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n\n  /proxy/tripo/v2/openapi/upload:\n    post:\n      summary: Upload File for 3D Generation\n      operationId: tripoUploadFile\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        content:\n          multipart/form-data:\n            schema:\n              type: object\n              properties:\n                file:\n                  type: string\n                  format: binary\n              required:\n                - file\n            encoding:\n              profileImage:\n                contentType: image/png, image/jpeg\n      responses:\n        \"200\":\n          description: Request successful\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  code:\n                    $ref: \"#/components/schemas/TripoResponseSuccessCode\"\n                  data:\n                    type: object\n                    properties:\n                      image_token:\n                        type: string\n                    required:\n                      - image_token\n                required:\n                  - code\n                  - data\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n\n  /proxy/tripo/v2/openapi/task:\n    post:\n      summary: Create 3D Generation Task\n      operationId: tripoCreateTask\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      requestBody:\n        content:\n          application/json:\n            schema:\n              oneOf:\n                - type: object\n                  properties:\n                    type:\n                      $ref: \"#/components/schemas/TripoTextToModel\"\n                    prompt:\n                      type: string\n                      maxLength: 1024\n                    negative_prompt:\n                      type: string\n                      maxLength: 1024\n                    model_version:\n                      $ref: \"#/components/schemas/TripoModelVersion\"\n                    face_limit:\n                      type: integer\n                    texture:\n                      type: boolean\n                      default: true\n                    pbr:\n                      type: boolean\n                      default: true\n                    text_seed:\n                      type: integer\n                    model_seed:\n                      type: integer\n                    texture_seed:\n                      type: integer\n                    texture_quality:\n                      $ref: \"#/components/schemas/TripoTextureQuality\"\n                      default: standard\n                    style:\n                      $ref: \"#/components/schemas/TripoModelStyle\"\n                    auto_size:\n                      type: boolean\n                      default: false\n                    quad:\n                      type: boolean\n                      default: false\n                    geometry_quality:\n                      $ref: \"#/components/schemas/TripoGeometryQuality\"\n                  required:\n                    - type\n                    - prompt\n                - type: object\n                  properties:\n                    type:\n                      $ref: \"#/components/schemas/TripoImageToModel\"\n                    file:\n                      type: object\n                      properties:\n                        type:\n                          type: string\n                        file_token:\n                          type: string\n                      required:\n                        - type\n                        - file_token\n                    model_version:\n                      $ref: \"#/components/schemas/TripoModelVersion\"\n                    face_limit:\n                      type: integer\n                    texture:\n                      type: boolean\n                      default: true\n                    pbr:\n                      type: boolean\n                      default: true\n                    model_seed:\n                      type: integer\n                    texture_seed:\n                      type: integer\n                    texture_quality:\n                      $ref: \"#/components/schemas/TripoTextureQuality\"\n                      default: standard\n                    texture_alignment:\n                      $ref: \"#/components/schemas/TripoTextureAlignment\"\n                      default: original_image\n                    style:\n                      $ref: \"#/components/schemas/TripoModelStyle\"\n                    auto_size:\n                      type: boolean\n                      default: false\n                    orientation:\n                      $ref: \"#/components/schemas/TripoOrientation\"\n                      default: default\n                    quad:\n                      type: boolean\n                      default: false\n                    geometry_quality:\n                      $ref: \"#/components/schemas/TripoGeometryQuality\"\n                  required:\n                    - type\n                    - file\n                - type: object\n                  properties:\n                    type:\n                      $ref: \"#/components/schemas/TripoMultiviewToModel\"\n                    files:\n                      type: array\n                      items:\n                        type: object\n                        properties:\n                          type:\n                            type: string\n                          file_token:\n                            type: string\n                        required:\n                          - type\n                          - file_token\n                    mode:\n                      $ref: \"#/components/schemas/TripoMultiviewMode\"\n                    model_version:\n                      $ref: \"#/components/schemas/TripoModelVersion\"\n                    orthographic_projection:\n                      type: boolean\n                      default: false\n                    face_limit:\n                      type: integer\n                    texture:\n                      type: boolean\n                      default: true\n                    pbr:\n                      type: boolean\n                      default: true\n                    model_seed:\n                      type: integer\n                    texture_seed:\n                      type: integer\n                    texture_quality:\n                      $ref: \"#/components/schemas/TripoTextureQuality\"\n                      default: standard\n                    texture_alignment:\n                      $ref: \"#/components/schemas/TripoTextureAlignment\"\n                      default: original_image\n                    auto_size:\n                      type: boolean\n                      default: false\n                    orientation:\n                      $ref: \"#/components/schemas/TripoOrientation\"\n                      default: default\n                    quad:\n                      type: boolean\n                      default: false\n                    geometry_quality:\n                      $ref: \"#/components/schemas/TripoGeometryQuality\"\n                  required:\n                    - type\n                    - files\n                - type: object\n                  properties:\n                    type:\n                      $ref: \"#/components/schemas/TripoTypeTextureModel\"\n                    texture:\n                      type: boolean\n                      default: true\n                    pbr:\n                      type: boolean\n                      default: true\n                    model_seed:\n                      type: integer\n                    texture_seed:\n                      type: integer\n                    texture_quality:\n                      $ref: \"#/components/schemas/TripoTextureQuality\"\n                    texture_alignment:\n                      $ref: \"#/components/schemas/TripoTextureAlignment\"\n                      default: original_image\n                    original_model_task_id:\n                      type: string\n                  required:\n                    - type\n                    - original_model_task_id\n                - type: object\n                  properties:\n                    type:\n                      $ref: \"#/components/schemas/TripoTypeRefineModel\"\n                    draft_model_task_id:\n                      type: string\n                  required:\n                    - type\n                    - draft_model_task_id\n                - type: object\n                  properties:\n                    type:\n                      $ref: \"#/components/schemas/TripoTypeAnimatePrerigcheck\"\n                    original_model_task_id:\n                      type: string\n                  required:\n                    - type\n                    - original_model_task_id\n                - type: object\n                  properties:\n                    type:\n                      $ref: \"#/components/schemas/TripoTypeAnimateRig\"\n                    original_model_task_id:\n                      type: string\n                    out_format:\n                      $ref: \"#/components/schemas/TripoStandardFormat\"\n                      default: glb\n                    topology:\n                      $ref: \"#/components/schemas/TripoTopology\"\n                    spec:\n                      $ref: \"#/components/schemas/TripoSpec\"\n                      default: \"tripo\"\n                  required:\n                    - type\n                    - original_model_task_id\n                - type: object\n                  properties:\n                    type:\n                      $ref: \"#/components/schemas/TripoTypeAnimateRetarget\"\n                    original_model_task_id:\n                      type: string\n                    out_format:\n                      $ref: \"#/components/schemas/TripoStandardFormat\"\n                      default: glb\n                    animation:\n                      $ref: \"#/components/schemas/TripoAnimation\"\n                    bake_animation:\n                      type: boolean\n                      default: true\n                  required:\n                    - type\n                    - original_model_task_id\n                    - animation\n                - type: object\n                  properties:\n                    type:\n                      $ref: \"#/components/schemas/TripoTypeStylizeModel\"\n                    style:\n                      $ref: \"#/components/schemas/TripoStylizeOptions\"\n                    original_model_task_id:\n                      type: string\n                    block_size:\n                      type: integer\n                      default: 80\n                  required:\n                    - type\n                    - style\n                    - original_model_task_id\n                - type: object\n                  properties:\n                    type:\n                      $ref: \"#/components/schemas/TripoTypeConvertModel\"\n                    format:\n                      $ref: \"#/components/schemas/TripoConvertFormat\"\n                    original_model_task_id:\n                      type: string\n                    quad:\n                      type: boolean\n                      default: false\n                    force_symmetry:\n                      type: boolean\n                      default: false\n                    face_limit:\n                      type: integer\n                      default: 10000\n                    flatten_bottom:\n                      type: boolean\n                      default: false\n                    flatten_bottom_threshold:\n                      type: number\n                      default: 0.01\n                    texture_size:\n                      type: integer\n                      default: 4096\n                    texture_format:\n                      $ref: \"#/components/schemas/TripoTextureFormat\"\n                      default: JPEG\n                    pivot_to_center_bottom:\n                      type: boolean\n                      default: false\n                  required:\n                    - type\n                    - format\n                    - original_model_task_id\n      responses:\n        \"200\":\n          description: Request successful\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoSuccessTask\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n\n  /proxy/tripo/v2/openapi/user/balance:\n    get:\n      summary: Query Account Balance\n      operationId: tripoGetBalance\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      responses:\n        \"200\":\n          description: Request successful\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  code:\n                    $ref: \"#/components/schemas/TripoResponseSuccessCode\"\n                  data:\n                    $ref: \"#/components/schemas/TripoBalance\"\n                required:\n                  - code\n                  - data\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"403\":\n          description: Unauthorized access to requested resource\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"404\":\n          description: Resource not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"429\":\n          description: Account exception or Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"503\":\n          description: Service temporarily unavailable\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n        \"504\":\n          description: Server timeout\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TripoErrorResponse\"\n\n  /proxy/rodin/api/v2/rodin:\n    post:\n      summary: Create 3D generate Task using Rodin API.\n      operationId: rodinGenerate3DAsset\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/Rodin3DGenerateRequest\"\n      responses:\n        \"200\":\n          description: 3D generate Task submitted successfully.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Rodin3DGenerateResponse\"\n        \"400\":\n          description: Bad Request\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden\n        \"404\":\n          description: Not Found\n        \"500\":\n          description: Internal Server Error\n  /proxy/rodin/api/v2/status:\n    post:\n      summary: Check Rodin 3D Generate Status.\n      operationId: rodinCheckStatus\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/Rodin3DCheckStatusRequest\"\n      responses:\n        \"200\":\n          description: Get the status of the 3D Assets generation.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Rodin3DCheckStatusResponse\"\n        \"400\":\n          description: Bad Request\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden\n        \"404\":\n          description: Not Found\n        \"500\":\n          description: Internal Server Error\n  /proxy/rodin/api/v2/download:\n    post:\n      summary: Get rodin 3D Assets download list.\n      operationId: rodinDownload\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/Rodin3DDownloadRequest\"\n      responses:\n        \"200\":\n          description: Get the download list for the Rodin 3D Assets.\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Rodin3DDownloadResponse\"\n        \"400\":\n          description: Bad Request\n        \"401\":\n          description: Unauthorized\n        \"403\":\n          description: Forbidden\n        \"404\":\n          description: Not Found\n        \"500\":\n          description: Internal Server Error\n\n  /proxy/moonvalley/prompts/{prompt_id}:\n    get:\n      x-excluded: true\n      summary: Get Prompt Details\n      parameters:\n        - name: prompt_id\n          in: path\n          required: true\n          schema:\n            type: string\n      responses:\n        \"200\":\n          description: Prompt details retrieved\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/MoonvalleyPromptResponse\"\n      operationId: MoonvalleyGetPrompt\n      tags:\n        - API Nodes\n  /proxy/moonvalley/prompts/text-to-video:\n    post:\n      x-excluded: true\n      summary: Create Text to Video Prompt\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/MoonvalleyTextToVideoRequest\"\n      responses:\n        \"201\":\n          description: Prompt created\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/MoonvalleyPromptResponse\"\n      operationId: MoonvalleyTextToVideo\n      tags:\n        - API Nodes\n      parameters: []\n  /proxy/moonvalley/prompts/text-to-image:\n    post:\n      x-excluded: true\n      summary: Create Text to Image Prompt\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/MoonvalleyTextToImageRequest\"\n      responses:\n        \"201\":\n          description: Prompt created\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/MoonvalleyPromptResponse\"\n      operationId: MoonvalleyTextToImage\n      tags:\n        - API Nodes\n      parameters: []\n  /proxy/moonvalley/prompts/image-to-video:\n    post:\n      x-excluded: true\n      summary: Create Image to Video Prompt\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/MoonvalleyImageToVideoRequest\"\n      responses:\n        \"201\":\n          description: Prompt created\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/MoonvalleyPromptResponse\"\n      operationId: MoonvalleyImageToVideo\n      tags:\n        - API Nodes\n      parameters: []\n  /proxy/moonvalley/prompts/video-to-video:\n    post:\n      x-excluded: true\n      summary: Create Video to Video Prompt\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/MoonvalleyVideoToVideoRequest\"\n      responses:\n        \"201\":\n          description: Prompt created\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/MoonvalleyPromptResponse\"\n      operationId: MoonvalleyVideoToVideo\n      tags:\n        - API Nodes\n      parameters: []\n  /proxy/moonvalley/prompts/video-to-video/resize:\n    post:\n      x-excluded: true\n      summary: Resize a video\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/MoonvalleyResizeVideoRequest\"\n      responses:\n        \"201\":\n          description: Prompt created\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/MoonvalleyPromptResponse\"\n      operationId: MoonvalleyVideoToVideoResize\n      tags:\n        - API Nodes\n      parameters: []\n  /proxy/moonvalley/uploads:\n    post:\n      x-excluded: true\n      summary: Upload Files\n      requestBody:\n        required: true\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/MoonvalleyUploadFileRequest\"\n      responses:\n        \"200\":\n          description: File uploaded successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/MoonvalleyUploadFileResponse\"\n      operationId: MoonvalleyUpload\n      tags:\n        - API Nodes\n      parameters: []\n  /proxy/vidu/img2video:\n    post:\n      tags:\n        - API Nodes\n        - Released\n      operationId: ViduImg2Video\n      requestBody:\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/ViduTaskRequest\"\n        required: true\n      responses:\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"200\":\n          description: OK\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ViduTaskReply\"\n        \"400\":\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n  /proxy/vidu/reference2video:\n    post:\n      tags:\n        - API Nodes\n        - Released\n      operationId: ViduReference2Video\n      requestBody:\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/ViduTaskRequest\"\n        required: true\n      responses:\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"200\":\n          description: OK\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ViduTaskReply\"\n        \"400\":\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n  /proxy/vidu/start-end2video:\n    post:\n      tags:\n        - API Nodes\n        - Released\n      operationId: ViduStartEnd2Video\n      requestBody:\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/ViduTaskRequest\"\n        required: true\n      responses:\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"200\":\n          description: OK\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ViduTaskReply\"\n        \"400\":\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n  /proxy/vidu/text2video:\n    post:\n      tags:\n        - API Nodes\n        - Released\n      operationId: ViduText2Video\n      requestBody:\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/ViduTaskRequest\"\n        required: true\n      responses:\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"200\":\n          description: OK\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ViduTaskReply\"\n        \"400\":\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n  /proxy/vidu/extend:\n    post:\n      tags:\n        - API Nodes\n        - Released\n      operationId: ViduExtend\n      requestBody:\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/ViduExtendRequest\"\n        required: true\n      responses:\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"200\":\n          description: OK\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ViduExtendReply\"\n        \"400\":\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n  /proxy/vidu/multiframe:\n    post:\n      tags:\n        - API Nodes\n        - Released\n      operationId: ViduMultiframe\n      requestBody:\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/ViduMultiframeRequest\"\n        required: true\n      responses:\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"200\":\n          description: OK\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ViduMultiframeReply\"\n        \"400\":\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n  /proxy/vidu/tasks/{id}/creations:\n    get:\n      tags:\n        - API Nodes\n        - Released\n      operationId: ViduGetCreations\n      parameters:\n        - name: id\n          in: path\n          required: true\n          schema:\n            type: string\n      responses:\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"200\":\n          description: OK\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ViduGetCreationsReply\"\n        \"400\":\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n  /proxy/byteplus/api/v3/images/generations:\n    post:\n      operationId: byteplusImageGeneration\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/BytePlusImageGenerationRequest\"\n      responses:\n        \"200\":\n          description: Image generation completed successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BytePlusImageGenerationResponse\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/byteplus/api/v3/contents/generations/tasks:\n    post:\n      operationId: byteplusVideoGeneration\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/BytePlusVideoGenerationRequest\"\n      responses:\n        \"200\":\n          description: Video generation task created successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BytePlusVideoGenerationResponse\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/byteplus/api/v3/contents/generations/tasks/{task_id}:\n    get:\n      operationId: byteplusVideoGenerationQuery\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: task_id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: The ID of the video generation task to query\n      responses:\n        \"200\":\n          description: Video generation task information retrieved successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BytePlusVideoGenerationQueryResponse\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/wan/api/v1/services/aigc/video-generation/video-synthesis:\n    post:\n      operationId: wanVideoGeneration\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/WanVideoGenerationRequest\"\n      responses:\n        \"200\":\n          description: Video generation task created successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/WanVideoGenerationResponse\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/wan/api/v1/services/aigc/text2image/image-synthesis:\n    post:\n      operationId: wanImageGeneration\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/WanImageGenerationRequest\"\n      responses:\n        \"200\":\n          description: Image generation task created successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/WanImageGenerationResponse\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/wan/api/v1/services/aigc/image2image/image-synthesis:\n    post:\n      operationId: wanImage2ImageGeneration\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/WanImage2ImageGenerationRequest\"\n      responses:\n        \"200\":\n          description: Image-to-image generation task created successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/WanImage2ImageGenerationResponse\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/wan/api/v1/tasks/{task_id}:\n    get:\n      operationId: wanTaskQueryProxy\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      parameters:\n        - in: path\n          name: task_id\n          required: true\n          schema:\n            type: string\n          description: The ID of the generation task to query\n      responses:\n        \"200\":\n          description: Generation task information retrieved successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/WanTaskQueryResponse\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/topaz/image/v1/enhance-gen/async:\n    post:\n      operationId: topazEnhanceGenAsync\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/TopazEnhanceGenRequest\"\n      responses:\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"200\":\n          description: Image processing request has been successfully created\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TopazEnhanceGenResponse\"\n  /proxy/topaz/image/v1/status/{process_id}:\n    get:\n      operationId: topazGetStatus\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      parameters:\n        - in: path\n          name: process_id\n          required: true\n          schema:\n            type: string\n          description: The process ID returned from the enhance-gen request\n      responses:\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"200\":\n          description: Status retrieved successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TopazStatusResponse\"\n  /proxy/topaz/image/v1/download/{process_id}:\n    get:\n      operationId: topazDownloadResult\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      parameters:\n        - in: path\n          name: process_id\n          required: true\n          schema:\n            type: string\n          description: The process ID returned from the enhance-gen request\n      responses:\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"200\":\n          description: Presigned download URL for the processed image\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TopazDownloadResponse\"\n  /proxy/topaz/video/:\n    post:\n      operationId: topazVideoCreate\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/TopazVideoCreateRequest\"\n      responses:\n        \"200\":\n          description: Video enhancement request created successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TopazVideoCreateResponse\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n  /proxy/topaz/video/{request_id}/accept:\n    patch:\n      operationId: topazVideoAccept\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      parameters:\n        - in: path\n          name: request_id\n          required: true\n          schema:\n            type: string\n          description: The request ID returned from the video create request\n      responses:\n        \"200\":\n          description: Video request accepted successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TopazVideoAcceptResponse\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n  /proxy/topaz/video/{request_id}/complete-upload:\n    patch:\n      operationId: topazVideoCompleteUpload\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      summary: Complete Video Upload\n      description: |\n        Send metadata of the multi-part uploads to complete the upload and begin processing the video.\n\n        Optionally include the MD5 hash of the source video file to validate successful upload before processing.\n      security:\n        - BearerAuth: []\n      parameters:\n        - in: path\n          name: request_id\n          required: true\n          schema:\n            type: string\n            format: uuid\n          description: The request ID returned from the video create request\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/TopazVideoCompleteUploadRequest\"\n      responses:\n        \"202\":\n          description: Video upload completed successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TopazVideoCompleteUploadResponse\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n  /proxy/topaz/video/{request_id}/status:\n    get:\n      operationId: topazVideoGetStatus\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      parameters:\n        - in: path\n          name: request_id\n          required: true\n          schema:\n            type: string\n          description: The request ID returned from the video create request\n      responses:\n        \"200\":\n          description: Video status retrieved successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TopazVideoStatusResponse\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n  /proxy/meshy/openapi/v2/text-to-3d:\n    post:\n      summary: Create a Text to 3D Preview Task\n      description: |\n        Create a new Text to 3D Preview task. This task costs 20 credits for Meshy-6 models and 5 credits for other models.\n      operationId: meshyTextTo3DCreate\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/MeshyTextTo3DRequest\"\n      responses:\n        \"200\":\n          description: Task created successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/MeshyTextTo3DCreateResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n  /proxy/meshy/openapi/v2/text-to-3d/{task_id}:\n    get:\n      summary: Get Text to 3D Task Status\n      description: Retrieve the status and result of a Text to 3D task.\n      operationId: meshyTextTo3DGetTask\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: task_id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: The unique identifier of the task\n      responses:\n        \"200\":\n          description: Task retrieved successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/MeshyTextTo3DTask\"\n        \"404\":\n          description: Task not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n  /proxy/meshy/openapi/v1/image-to-3d:\n    post:\n      summary: Create an Image to 3D Task\n      description: |\n        Create a new Image to 3D task. This task generates a 3D model from an image input.\n      operationId: meshyImageTo3DCreate\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/MeshyImageTo3DRequest\"\n      responses:\n        \"200\":\n          description: Task created successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/MeshyImageTo3DCreateResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n  /proxy/meshy/openapi/v1/image-to-3d/{task_id}:\n    get:\n      summary: Get Image to 3D Task Status\n      description: Retrieve the status and result of an Image to 3D task.\n      operationId: meshyImageTo3DGetTask\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: task_id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: The unique identifier of the task\n      responses:\n        \"200\":\n          description: Task retrieved successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/MeshyImageTo3DTask\"\n        \"404\":\n          description: Task not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n  /proxy/meshy/openapi/v1/multi-image-to-3d:\n    post:\n      summary: Create a Multi-Image to 3D Task\n      description: |\n        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.\n        Mesh generation uses Meshy-5 model, while texture generation supports Meshy-6-preview model.\n      operationId: meshyMultiImageTo3DCreate\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/MeshyMultiImageTo3DRequest\"\n      responses:\n        \"200\":\n          description: Task created successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/MeshyMultiImageTo3DCreateResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n  /proxy/meshy/openapi/v1/multi-image-to-3d/{task_id}:\n    get:\n      summary: Get Multi-Image to 3D Task Status\n      description: Retrieve the status and result of a Multi-Image to 3D task.\n      operationId: meshyMultiImageTo3DGetTask\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: task_id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: The unique identifier of the task\n      responses:\n        \"200\":\n          description: Task retrieved successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/MeshyMultiImageTo3DTask\"\n        \"404\":\n          description: Task not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n  /proxy/meshy/openapi/v1/remesh:\n    post:\n      summary: Create a Remesh Task\n      description: |\n        Create a new remesh task to remesh and export an existing 3D model into various formats.\n      operationId: meshyRemeshCreate\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/MeshyRemeshRequest\"\n      responses:\n        \"200\":\n          description: Task created successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/MeshyRemeshCreateResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n  /proxy/meshy/openapi/v1/remesh/{task_id}:\n    get:\n      summary: Get Remesh Task Status\n      description: Retrieve the status and result of a Remesh task.\n      operationId: meshyRemeshGetTask\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: task_id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: The unique identifier of the task\n      responses:\n        \"200\":\n          description: Task retrieved successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/MeshyRemeshTask\"\n        \"404\":\n          description: Task not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n  /proxy/meshy/openapi/v1/rigging:\n    post:\n      summary: Create a Rigging Task\n      description: |\n        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.\n      operationId: meshyRiggingCreate\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/MeshyRiggingRequest\"\n      responses:\n        \"200\":\n          description: Task created successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/MeshyRiggingCreateResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n  /proxy/meshy/openapi/v1/rigging/{task_id}:\n    get:\n      summary: Get Rigging Task Status\n      description: Retrieve the status and result of a Rigging task.\n      operationId: meshyRiggingGetTask\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: task_id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: The unique identifier of the task\n      responses:\n        \"200\":\n          description: Task retrieved successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/MeshyRiggingTask\"\n        \"404\":\n          description: Task not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n  /proxy/meshy/openapi/v1/retexture:\n    post:\n      summary: Create a Retexture Task\n      description: |\n        Create a new Retexture task to generate 3D texture from text or image inputs.\n      operationId: meshyRetextureCreate\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/MeshyRetextureRequest\"\n      responses:\n        \"200\":\n          description: Task created successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/MeshyRetextureCreateResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n  /proxy/meshy/openapi/v1/retexture/{task_id}:\n    get:\n      summary: Get Retexture Task Status\n      description: Retrieve the status and result of a Retexture task.\n      operationId: meshyRetextureGetTask\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: task_id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: The unique identifier of the task\n      responses:\n        \"200\":\n          description: Task retrieved successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/MeshyRetextureTask\"\n        \"404\":\n          description: Task not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n  /proxy/meshy/openapi/v1/animations:\n    post:\n      summary: Create an Animation Task\n      description: |\n        Create a new task to apply a specific animation action to a previously rigged character. Includes post-processing options.\n      operationId: meshyAnimationCreate\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/MeshyAnimationRequest\"\n      responses:\n        \"200\":\n          description: Task created successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/MeshyAnimationCreateResponse\"\n        \"400\":\n          description: Invalid request parameters\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"401\":\n          description: Authentication failed\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n  /proxy/meshy/openapi/v1/animations/{task_id}:\n    get:\n      summary: Get Animation Task Status\n      description: Retrieve the status and result of an Animation task.\n      operationId: meshyAnimationGetTask\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: task_id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: The unique identifier of the task\n      responses:\n        \"200\":\n          description: Task retrieved successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/MeshyAnimationTask\"\n        \"404\":\n          description: Task not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        default:\n          description: Error 4xx/5xx\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n  /proxy/xai/v1/images/generations:\n    post:\n      summary: Generate images using xAI Grok Imagine\n      description: Generate one or more images from a text prompt using the Grok Imagine API.\n      operationId: xaiImageGenerate\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/XAIImageGenerationRequest\"\n      responses:\n        \"200\":\n          description: Images generated successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/XAIImageGenerationResponse\"\n        \"400\":\n          description: Bad request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /proxy/xai/v1/images/edits:\n    post:\n      summary: Edit images using xAI Grok Imagine\n      description: Modify an existing image based on a text prompt using the Grok Imagine API.\n      operationId: xaiImageEdit\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/XAIImageEditRequest\"\n      responses:\n        \"200\":\n          description: Image edited successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/XAIImageGenerationResponse\"\n        \"400\":\n          description: Bad request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /proxy/xai/v1/videos/generations:\n    post:\n      summary: Generate videos using xAI Grok Imagine\n      description: |\n        Generate a video from a text prompt (text-to-video) or from an image with optional text (image-to-video).\n        Video generation is asynchronous. Returns a request_id to poll for the completed video.\n      operationId: xaiVideoGenerate\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/XAIVideoGenerationRequest\"\n      responses:\n        \"200\":\n          description: Video generation job created successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/XAIVideoAsyncResponse\"\n        \"400\":\n          description: Bad request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /proxy/xai/v1/videos/edits:\n    post:\n      summary: Edit videos using xAI Grok Imagine\n      description: |\n        Edit an existing video based on a text prompt (video-to-video editing).\n        Video editing is asynchronous. Returns a request_id to poll for the completed video.\n        Input video limit is 8 seconds. Audio will not be modified.\n      operationId: xaiVideoEdit\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/XAIVideoEditRequest\"\n      responses:\n        \"200\":\n          description: Video editing job created successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/XAIVideoAsyncResponse\"\n        \"400\":\n          description: Bad request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n\n  /proxy/xai/v1/videos/{request_id}:\n    get:\n      summary: Get xAI video generation result\n      description: |\n        Retrieve the result of a video generation or editing request.\n        Poll this endpoint until the response includes a video object with the completed video URL.\n      operationId: xaiVideoGetResult\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: request_id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: The request ID returned by the video generation or editing endpoint\n      responses:\n        \"200\":\n          description: Video generation result\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/XAIVideoResultResponse\"\n        \"202\":\n          description: Video generation still pending\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/XAIVideoResultResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"404\":\n          description: Request ID not found\n        \"500\":\n          description: Internal server error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  \n  /proxy/reve/v1/image/create:\n    post:\n      summary: Generate an image using Reve\n      description: Forwards image creation requests to the Reve API and returns the generated image.\n      operationId: reveImageCreate\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/ReveImageCreateRequest\"\n      responses:\n        \"200\":\n          description: Successful response from Reve proxy\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ReveImageResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"429\":\n          description: Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/reve/v1/image/edit:\n    post:\n      summary: Edit an image using Reve\n      description: Forwards image editing requests to the Reve API with an edit instruction and reference image.\n      operationId: reveImageEdit\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/ReveImageEditRequest\"\n      responses:\n        \"200\":\n          description: Successful response from Reve proxy\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ReveImageResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"429\":\n          description: Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/reve/v1/image/remix:\n    post:\n      summary: Remix images using Reve\n      description: Forwards image remix requests to the Reve API with reference images and a text prompt.\n      operationId: reveImageRemix\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/ReveImageRemixRequest\"\n      responses:\n        \"200\":\n          description: Successful response from Reve proxy\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ReveImageResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n        \"429\":\n          description: Rate limit exceeded\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/bria/v2/image/edit:\n    post:\n      summary: Edit an image using Bria FIBO\n      description: |\n        Edit an existing image using Bria's FIBO Edit API. You can provide:\n        1. A source image and a text-based instruction (prompt)\n        2. A source image and a structured_instruction\n        3. A source image, a mask, and a text-based instruction\n        4. A source image, a mask, and a structured_instruction\n        \n        This endpoint always uses async mode (sync: false) and returns a status_url to poll for results.\n      operationId: briaFiboEdit\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/BriaFiboEditRequest\"\n      responses:\n        \"202\":\n          description: Request accepted, processing asynchronously\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BriaAsyncResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"422\":\n          description: Content moderation failure\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BriaErrorResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/bria/v2/structured_instruction/generate:\n    post:\n      summary: Generate a structured instruction from text\n      description: |\n        Translates a user's text-based edit instruction and source image/mask into a detailed, \n        machine-readable structured edit instruction in JSON format.\n        \n        This endpoint uses Gemini 2.5 Flash VLM to understand the edit context and returns only \n        the JSON string without generating an image.\n        \n        The resulting structured_instruction can be used as input for the /proxy/bria/v2/image/edit endpoint.\n        \n        This endpoint always uses async mode (sync: false) and returns a status_url to poll for results.\n      operationId: briaStructuredInstructionGenerate\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/BriaStructuredInstructionRequest\"\n      responses:\n        \"202\":\n          description: Request accepted, processing asynchronously\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BriaAsyncResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"422\":\n          description: Content moderation failure\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BriaErrorResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/bria/v2/status/{request_id}:\n    get:\n      summary: Get Bria request status\n      description: |\n        Retrieves the current status of an asynchronous Bria request.\n\n        Poll this endpoint until the status is COMPLETED or ERROR.\n\n        Status values:\n        - `IN_PROGRESS` – Request is being processed. Continue polling.\n        - `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.\n        - `ERROR` – Processing failed. Check error object for details.\n        - `UNKNOWN` – Unexpected internal error.\n      operationId: briaGetStatus\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: request_id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: Unique identifier of the request (returned from edit, generate, or remove_background endpoints)\n      responses:\n        \"200\":\n          description: Status retrieved successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BriaStatusResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: Request ID not found or expired\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BriaStatusNotFoundResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/bria/v2/video/edit/remove_background:\n    post:\n      summary: Remove background from a video using Bria\n      description: |\n        Initiates an asynchronous background removal job for a video using Bria's API.\n\n        Returns HTTP 202 with request_id and status_url. Poll the status endpoint for results.\n\n        Supported input containers: .mp4, .mov, .webm, .avi, .gif\n        Supported input codecs: H.264, H.265 (HEVC), VP9, AV1, PhotoJPEG\n        Max input duration: 60 seconds. Input resolution up to 16000x16000.\n      operationId: briaVideoRemoveBackground\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/BriaVideoRemoveBackgroundRequest\"\n      responses:\n        \"202\":\n          description: Request accepted, processing asynchronously\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BriaAsyncResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BriaErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"422\":\n          description: Unprocessable Entity\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BriaErrorResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/bria/v2/image/edit/remove_background:\n    post:\n      summary: Remove background from an image using Bria\n      description: |\n        Remove the background of an image using Bria's RMBG 2.0 model.\n\n        Returns HTTP 202 with request_id and status_url when async (default).\n        Can return 200 with result directly when sync is true.\n\n        Accepted image formats: JPEG, JPG, PNG, WEBP.\n      operationId: briaImageRemoveBackground\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/BriaImageRemoveBackgroundRequest\"\n      responses:\n        \"202\":\n          description: Request accepted, processing asynchronously\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BriaAsyncResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BriaErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"422\":\n          description: Content moderation failure\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/BriaErrorResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/wavespeed/api/v3/wavespeed-ai/flashvsr:\n    post:\n      summary: Submit a FlashVSR video upscaling task\n      description: |\n        Submit a video for upscaling using WavespeedAI's FlashVSR model.\n        FlashVSR is a fast, high-quality video upscaler that boosts resolution and restores clarity\n        for low-resolution or blurry footage.\n        \n        Supported target resolutions: 720p, 1080p, 2k, 4k\n        \n        Max clip length: up to 10 minutes\n        Processing speed: approximately 3-20 seconds of wall time to process 1 second of video\n        \n        Returns a task ID that can be used to poll for the result.\n      operationId: wavespeedFlashVSRSubmit\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/WavespeedFlashVSRRequest\"\n      responses:\n        \"200\":\n          description: Task submitted successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/WavespeedTaskResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/wavespeed/api/v3/predictions/{prediction_id}/result:\n    get:\n      summary: Get FlashVSR task result\n      description: |\n        Retrieve the status and result of a FlashVSR video upscaling task.\n        \n        Poll this endpoint until status is \"completed\" or \"failed\".\n        \n        Status values:\n        - `created` - Task has been created\n        - `processing` - Task is being processed\n        - `completed` - Task completed successfully, outputs array contains result URLs\n        - `failed` - Task failed, check error field for details\n      operationId: wavespeedFlashVSRGetResult\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: prediction_id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: The unique identifier of the prediction/task\n      responses:\n        \"200\":\n          description: Task result retrieved successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/WavespeedTaskResultResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"404\":\n          description: Task not found\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/wavespeed/api/v3/wavespeed-ai/seedvr2/image:\n    post:\n      summary: Submit a SeedVR2 image upscaling task\n      description: |\n        Upscale an image using WavespeedAI's SeedVR2 Image Upscaler.\n        SeedVR2 boosts image resolution and quality, upscaling photos to 2K, 4K, or 8K\n        for sharp, detailed results.\n      operationId: wavespeedSeedVR2ImageSubmit\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/WavespeedSeedVR2ImageRequest\"\n      responses:\n        \"200\":\n          description: Task submitted successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/WavespeedTaskResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/wavespeed/api/v3/wavespeed-ai/ultimate-image-upscaler:\n    post:\n      summary: Submit an Ultimate Image Upscaler task\n      description: |\n        Upscale an image using WavespeedAI's Ultimate Image Upscaler.\n        The most advanced AI enhancer that reimagines fine detail while upscaling images to 2K, 4K, or 8K.\n      operationId: wavespeedUltimateImageUpscalerSubmit\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Released\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/WavespeedSeedVR2ImageRequest\"\n      responses:\n        \"200\":\n          description: Task submitted successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/WavespeedTaskResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n  /proxy/tencent/hunyuan/3d-pro:\n    post:\n      summary: Submit Tencent Hunyuan 3D Pro Generation Task\n      description: |\n        Submit a task to generate 3D content using Tencent HunYuan Large Model.\n        Supports text-to-3D and image-to-3D generation.\n        \n        This API provides 3 concurrent tasks by default. A new task can be processed \n        only after the previous one is completed.\n        \n        The returned JobId can be used with the query endpoint to check task status.\n      operationId: tencentHunyuan3DProSubmit\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Tencent\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/TencentHunyuan3DProRequest\"\n      responses:\n        \"200\":\n          description: Task submitted successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentHunyuan3DProResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentErrorResponse\"\n  /proxy/tencent/hunyuan/3d-pro/query:\n    post:\n      summary: Query Tencent Hunyuan 3D Pro Task Status\n      description: |\n        Query the status and result of a previously submitted 3D generation task.\n        \n        Poll this endpoint until the task status indicates completion.\n      operationId: tencentHunyuan3DProQuery\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Tencent\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/TencentHunyuan3DQueryRequest\"\n      responses:\n        \"200\":\n          description: Task status retrieved successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentHunyuan3DQueryResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentErrorResponse\"\n\n  /proxy/tencent/hunyuan/3d-uv:\n    post:\n      summary: Submit Tencent Hunyuan 3D UV Unfolding Task\n      description: |\n        Submit a UV unwrapping task for a 3D model using Tencent Hunyuan.\n        After inputting the model, UV unwrapping can be performed based on the \n        model texture to output the corresponding UV map.\n        \n        The returned JobId can be used with the query endpoint to check task status.\n      operationId: tencentHunyuan3DUVSubmit\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Tencent\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/TencentHunyuan3DUVRequest\"\n      responses:\n        \"200\":\n          description: Task submitted successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentHunyuan3DUVResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentErrorResponse\"\n\n  /proxy/tencent/hunyuan/3d-uv/query:\n    post:\n      summary: Query Tencent Hunyuan 3D UV Unfolding Task Status\n      description: |\n        Query the status and result of a previously submitted UV unwrapping task.\n        \n        Poll this endpoint until the task status indicates completion.\n      operationId: tencentHunyuan3DUVQuery\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Tencent\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/TencentHunyuan3DQueryRequest\"\n      responses:\n        \"200\":\n          description: Task status retrieved successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentHunyuan3DQueryResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentErrorResponse\"\n\n  /proxy/tencent/hunyuan/3d-texture-edit:\n    post:\n      summary: Submit Tencent Hunyuan 3D Texture Edit Task\n      description: |\n        Submit a 3D model texture redrawing task using Tencent Hunyuan.\n        After inputting the 3D model, perform 3D model texture redrawing based on semantics or images.\n        Supported format: FBX. 3D model limit: less than 100000 faces.\n        Either Image or Prompt is required; they cannot coexist. EnablePBR only supports enabling when using Prompt.\n        \n        The returned JobId can be used with the query endpoint to check task status.\n      operationId: tencentHunyuan3DTextureEditSubmit\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Tencent\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/TencentHunyuan3DTextureEditRequest\"\n      responses:\n        \"200\":\n          description: Task submitted successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentHunyuan3DUVResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentErrorResponse\"\n\n  /proxy/tencent/hunyuan/3d-texture-edit/query:\n    post:\n      summary: Query Tencent Hunyuan 3D Texture Edit Task Status\n      description: |\n        Query the status and result of a previously submitted 3D texture edit task.\n        \n        Poll this endpoint until the task status indicates completion.\n      operationId: tencentHunyuan3DTextureEditQuery\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Tencent\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/TencentHunyuan3DQueryRequest\"\n      responses:\n        \"200\":\n          description: Task status retrieved successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentHunyuan3DQueryResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentErrorResponse\"\n\n  /proxy/tencent/hunyuan/3d-part:\n    post:\n      summary: Submit Tencent Hunyuan 3D Part (Component Splitting) Task\n      description: |\n        Submit a component identification and generation task using Tencent Hunyuan.\n        Automatically performs component splitting based on the model structure after inputting a 3D model file.\n        Recommends inputting 3D models generated by AIGC. File size not greater than 100MB, face count not greater than 30,000. FBX format only.\n        \n        The returned JobId can be used with the query endpoint to check task status.\n      operationId: tencentHunyuan3DPartSubmit\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Tencent\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/TencentHunyuan3DUVRequest\"\n      responses:\n        \"200\":\n          description: Task submitted successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentHunyuan3DUVResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentErrorResponse\"\n\n  /proxy/tencent/hunyuan/3d-part/query:\n    post:\n      summary: Query Tencent Hunyuan 3D Part Task Status\n      description: |\n        Query the status and result of a previously submitted 3D part (component splitting) task.\n        \n        Poll this endpoint until the task status indicates completion.\n      operationId: tencentHunyuan3DPartQuery\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Tencent\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/TencentHunyuan3DQueryRequest\"\n      responses:\n        \"200\":\n          description: Task status retrieved successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentHunyuan3DQueryResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentErrorResponse\"\n\n  /proxy/tencent/hunyuan/3d-smart-topology:\n    post:\n      summary: Submit Tencent Hunyuan 3D Smart Topology Task\n      description: |\n        Submit a 3D smart topology (retopology/polygon reduction) task using Tencent Hunyuan.\n        Takes an input 3D model and performs intelligent topology optimization.\n        Supported input formats: GLB, OBJ. File size max 200MB.\n\n        The returned JobId can be used with the query endpoint to check task status.\n      operationId: tencentHunyuan3DSmartTopologySubmit\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Tencent\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/TencentHunyuan3DSmartTopologyRequest\"\n      responses:\n        \"200\":\n          description: Task submitted successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentHunyuan3DUVResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentErrorResponse\"\n\n  /proxy/tencent/hunyuan/3d-smart-topology/query:\n    post:\n      summary: Query Tencent Hunyuan 3D Smart Topology Task Status\n      description: |\n        Query the status and result of a previously submitted 3D smart topology task.\n\n        Poll this endpoint until the task status indicates completion.\n      operationId: tencentHunyuan3DSmartTopologyQuery\n      x-excluded: true\n      tags:\n        - API Nodes\n        - Tencent\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/TencentHunyuan3DQueryRequest\"\n      responses:\n        \"200\":\n          description: Task status retrieved successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentHunyuan3DQueryResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/TencentErrorResponse\"\n\n  /proxy/hitpaw/api/photo-enhancer:\n    post:\n      summary: Submit HitPaw Photo Enhancement Task\n      description: |\n        Submit an image processing task using HitPaw Photo Enhancement API.\n        Supports multiple enhancement models for image super-resolution processing.\n        \n        The returned job_id can be used with the task-status endpoint to check processing results.\n        \n        **Available Models:**\n        - Enhancement & Denoise Models (face_2x/4x, face_v2_2x/4x, general_2x/4x, high_fidelity_2x/4x, sharpen_denoise, detail_denoise):\n          - Max input: 67 MP, Max output: 600 MP\n          - Supported formats: bmp, jpeg, jpg, png, jfif, tga, tiff, webp, heif\n        - Generative Models (generative_portrait, generative):\n          - No input limit, Max output: 8K (33 MP)\n          - Supported formats: bmp, jpeg, jpg, png, jfif, tga, tiff, webp, heif\n      operationId: hitpawPhotoEnhancer\n      x-excluded: true\n      tags:\n        - API Nodes\n        - HitPaw\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/HitPawPhotoEnhancerRequest\"\n      responses:\n        \"200\":\n          description: Task submitted successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/HitPawJobResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/HitPawErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ErrorResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/HitPawErrorResponse\"\n\n  /proxy/hitpaw/api/task-status:\n    post:\n      summary: Query HitPaw Task Status\n      description: |\n        Query the status and result of a previously submitted photo or video enhancement task.\n        Poll this endpoint until the task status indicates completion (COMPLETED).\n        \n        **Status Codes:**\n        - CONVERTING: Job is currently being processed\n        - COMPLETED: Job has completed successfully, result is available\n        - ERROR: Job failed due to an error\n      operationId: hitpawTaskStatus\n      x-excluded: true\n      tags:\n        - API Nodes\n        - HitPaw\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/HitPawTaskStatusRequest\"\n      responses:\n        \"200\":\n          description: Task status retrieved successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/HitPawTaskStatusResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/HitPawErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/HitPawErrorResponse\"\n\n  /proxy/hitpaw/api/video-enhancer:\n    post:\n      summary: Submit HitPaw Video Enhancement Task\n      description: |\n        Submit a video processing task using HitPaw Video Enhancement API.\n        Uses AI technology to upscale low-resolution videos to high resolution,\n        eliminate artifacts and noise, and improve clarity and details.\n        \n        The returned job_id can be used with the task-status endpoint to check processing results.\n        \n        **Video Constraints:**\n        - Duration: 0.5 seconds to 1 hour\n        - Maximum output resolution: 36 MP (Total Pixels)\n        - 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\n        - Supported output formats: mp4, mov, mkv, m4v, avi, gif\n      operationId: hitpawVideoEnhancer\n      x-excluded: true\n      tags:\n        - API Nodes\n        - HitPaw\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/HitPawVideoEnhancerRequest\"\n      responses:\n        \"200\":\n          description: Task submitted successfully\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/HitPawJobResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/HitPawErrorResponse\"\n        \"401\":\n          description: Unauthorized\n        \"402\":\n          description: Payment Required - Insufficient credits\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/HitPawErrorResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/HitPawErrorResponse\"\n\n  /proxy/elevenlabs/v1/text-to-speech/{voice_id}:\n    post:\n      summary: ElevenLabs Text to Speech\n      description: |\n        Converts text into speech using a specified voice and returns audio.\n        \n        The output format can be specified via the output_format query parameter.\n        Supported formats include MP3, PCM, μ-law, and Opus with various sample rates and bitrates.\n      operationId: ElevenLabsTextToSpeech\n      x-excluded: true\n      tags:\n        - API Nodes\n        - ElevenLabs\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: voice_id\n          in: path\n          description: ID of the voice to use. Use the Get voices endpoint to list all available voices.\n          required: true\n          schema:\n            type: string\n        - name: enable_logging\n          in: query\n          description: When set to false, enables zero retention mode (enterprise only). History features will be unavailable.\n          required: false\n          schema:\n            type: boolean\n            default: true\n        - name: optimize_streaming_latency\n          in: query\n          description: |\n            Deprecated. Latency optimization levels (0-4):\n            0 - default mode (no latency optimizations)\n            1 - normal latency optimizations (~50% improvement)\n            2 - strong latency optimizations (~75% improvement)\n            3 - max latency optimizations\n            4 - max latency with text normalizer off (best latency but may mispronounce)\n          required: false\n          schema:\n            type: integer\n            minimum: 0\n            maximum: 4\n        - name: output_format\n          in: query\n          description: |\n            Output format of the generated audio. Formatted as codec_sample_rate_bitrate.\n            Examples: mp3_22050_32, mp3_44100_128, pcm_16000, pcm_22050, ulaw_8000\n          required: false\n          schema:\n            type: string\n            enum:\n              - mp3_22050_32\n              - mp3_44100_32\n              - mp3_44100_64\n              - mp3_44100_96\n              - mp3_44100_128\n              - mp3_44100_192\n              - pcm_8000\n              - pcm_16000\n              - pcm_22050\n              - pcm_24000\n              - pcm_32000\n              - pcm_44100\n              - pcm_48000\n              - ulaw_8000\n              - alaw_8000\n              - opus_48000_32\n              - opus_48000_64\n              - opus_48000_96\n              - opus_48000_128\n              - opus_48000_192\n              - wav_8000\n              - wav_16000\n              - wav_22050\n              - wav_24000\n              - wav_32000\n              - wav_44100\n              - wav_48000\n            default: mp3_44100_128\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/ElevenLabsTTSRequest\"\n      responses:\n        \"200\":\n          description: The generated audio file\n          content:\n            audio/mpeg:\n              schema:\n                type: string\n                format: binary\n            audio/wav:\n              schema:\n                type: string\n                format: binary\n            audio/ogg:\n              schema:\n                type: string\n                format: binary\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"401\":\n          description: Unauthorized\n        \"422\":\n          description: Validation Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ElevenLabsValidationError\"\n\n  /proxy/elevenlabs/v1/speech-to-text:\n    post:\n      summary: Create transcript (Speech-to-Text)\n      description: |\n        Transcribe an audio or video file. If webhook is set to true, the request will be processed \n        asynchronously and results sent to configured webhooks. When use_multi_channel is true and \n        the provided audio has multiple channels, a 'transcripts' object with separate transcripts \n        for each channel is returned. Otherwise, returns a single transcript. The optional \n        webhook_metadata parameter allows you to attach custom data that will be included in \n        webhook responses for request correlation and tracking.\n      operationId: ElevenLabsSpeechToText\n      x-excluded: true\n      tags:\n        - API Nodes\n        - ElevenLabs\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: enable_logging\n          in: query\n          description: |\n            When enable_logging is set to false zero retention mode will be used for the request. \n            This will mean log and transcript storage features are unavailable for this request. \n            Zero retention mode may only be used by enterprise customers.\n          required: false\n          schema:\n            type: boolean\n            default: true\n      requestBody:\n        required: true\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/ElevenLabsSTTRequest\"\n      responses:\n        \"200\":\n          description: Synchronous transcription result\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ElevenLabsSTTResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"401\":\n          description: Unauthorized\n        \"422\":\n          description: Validation Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ElevenLabsValidationError\"\n\n  /proxy/elevenlabs/v1/speech-to-speech/{voice_id}:\n    post:\n      summary: Voice Changer (Speech-to-Speech)\n      description: |\n        Transform audio from one voice to another. Maintain full control over emotion, timing and delivery.\n      operationId: ElevenLabsSpeechToSpeech\n      x-excluded: true\n      tags:\n        - API Nodes\n        - ElevenLabs\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: voice_id\n          in: path\n          description: ID of the voice to be used. Use the Get voices endpoint to list all available voices.\n          required: true\n          schema:\n            type: string\n        - name: enable_logging\n          in: query\n          description: |\n            When enable_logging is set to false zero retention mode will be used for the request.\n            This will mean history features are unavailable for this request, including request stitching.\n            Zero retention mode may only be used by enterprise customers.\n          required: false\n          schema:\n            type: boolean\n            default: true\n        - name: optimize_streaming_latency\n          in: query\n          description: |\n            Latency optimization levels (0-4):\n            0 - default mode (no latency optimizations)\n            1 - normal latency optimizations (~50% improvement)\n            2 - strong latency optimizations (~75% improvement)\n            3 - max latency optimizations\n            4 - max latency with text normalizer off (best latency but may mispronounce)\n          required: false\n          schema:\n            type: integer\n            nullable: true\n        - name: output_format\n          in: query\n          description: |\n            Output format of the generated audio. Formatted as codec_sample_rate_bitrate.\n            Examples: mp3_22050_32, mp3_44100_128, pcm_16000, ulaw_8000\n          required: false\n          schema:\n            type: string\n            enum:\n              - mp3_22050_32\n              - mp3_24000_48\n              - mp3_44100_32\n              - mp3_44100_64\n              - mp3_44100_96\n              - mp3_44100_128\n              - mp3_44100_192\n              - pcm_8000\n              - pcm_16000\n              - pcm_22050\n              - pcm_24000\n              - pcm_32000\n              - pcm_44100\n              - pcm_48000\n              - ulaw_8000\n              - alaw_8000\n              - opus_48000_32\n              - opus_48000_64\n              - opus_48000_96\n              - opus_48000_128\n              - opus_48000_192\n            default: mp3_44100_128\n      requestBody:\n        required: true\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/ElevenLabsSpeechToSpeechRequest\"\n      responses:\n        \"200\":\n          description: The generated audio file\n          content:\n            audio/mpeg:\n              schema:\n                type: string\n                format: binary\n            audio/wav:\n              schema:\n                type: string\n                format: binary\n            audio/ogg:\n              schema:\n                type: string\n                format: binary\n            application/octet-stream:\n              schema:\n                type: string\n                format: binary\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"401\":\n          description: Unauthorized\n        \"422\":\n          description: Validation Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ElevenLabsValidationError\"\n\n  /proxy/elevenlabs/v1/audio-isolation:\n    post:\n      summary: Audio Isolation\n      description: |\n        Removes background noise from audio. Isolates vocals/speech from background sounds.\n      operationId: ElevenLabsAudioIsolation\n      x-excluded: true\n      tags:\n        - API Nodes\n        - ElevenLabs\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/ElevenLabsAudioIsolationRequest\"\n      responses:\n        \"200\":\n          description: The isolated audio file\n          content:\n            audio/mpeg:\n              schema:\n                type: string\n                format: binary\n            application/octet-stream:\n              schema:\n                type: string\n                format: binary\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"401\":\n          description: Unauthorized\n        \"422\":\n          description: Validation Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ElevenLabsValidationError\"\n\n  /proxy/elevenlabs/v1/voices/add:\n    post:\n      summary: Create Voice Clone\n      description: |\n        Create an instant voice clone and add it to your Voices.\n      operationId: ElevenLabsCreateVoice\n      x-excluded: true\n      tags:\n        - API Nodes\n        - ElevenLabs\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          multipart/form-data:\n            schema:\n              $ref: \"#/components/schemas/ElevenLabsCreateVoiceRequest\"\n      responses:\n        \"200\":\n          description: Voice created successfully\n          content:\n            application/json:\n              schema:\n                type: object\n                properties:\n                  voice_id:\n                    type: string\n                  requires_verification:\n                    type: boolean\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"401\":\n          description: Unauthorized\n        \"422\":\n          description: Validation Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ElevenLabsValidationError\"\n\n  /proxy/elevenlabs/v1/sound-generation:\n    post:\n      summary: Create Sound Effect\n      description: |\n        Turn text into sound effects for your videos, voice-overs or video games\n        using the most advanced sound effects models in the world.\n      operationId: ElevenLabsSoundGeneration\n      x-excluded: true\n      tags:\n        - API Nodes\n        - ElevenLabs\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: output_format\n          in: query\n          description: |\n            Output format of the generated audio. Formatted as codec_sample_rate_bitrate.\n            Examples: mp3_22050_32, mp3_44100_128, pcm_16000, ulaw_8000\n          required: false\n          schema:\n            type: string\n            enum:\n              - mp3_22050_32\n              - mp3_24000_48\n              - mp3_44100_32\n              - mp3_44100_64\n              - mp3_44100_96\n              - mp3_44100_128\n              - mp3_44100_192\n              - pcm_8000\n              - pcm_16000\n              - pcm_22050\n              - pcm_24000\n              - pcm_32000\n              - pcm_44100\n              - pcm_48000\n              - ulaw_8000\n              - alaw_8000\n              - opus_48000_32\n              - opus_48000_64\n              - opus_48000_96\n              - opus_48000_128\n              - opus_48000_192\n            default: mp3_44100_128\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/ElevenLabsSoundGenerationRequest\"\n      responses:\n        \"200\":\n          description: The generated sound effect audio file\n          content:\n            audio/mpeg:\n              schema:\n                type: string\n                format: binary\n            application/octet-stream:\n              schema:\n                type: string\n                format: binary\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"401\":\n          description: Unauthorized\n        \"422\":\n          description: Validation Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ElevenLabsValidationError\"\n\n  /proxy/elevenlabs/v1/text-to-dialogue:\n    post:\n      summary: Create dialogue (Multi-voice TTS)\n      description: |\n        Converts a list of text and voice ID pairs into speech (dialogue) and returns audio.\n        Useful for generating conversations between multiple characters.\n      operationId: ElevenLabsTextToDialogue\n      x-excluded: true\n      tags:\n        - API Nodes\n        - ElevenLabs\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: output_format\n          in: query\n          description: |\n            Output format of the generated audio. Formatted as codec_sample_rate_bitrate.\n            Examples: mp3_22050_32, mp3_44100_128, pcm_16000, ulaw_8000\n          required: false\n          schema:\n            type: string\n            enum:\n              - mp3_22050_32\n              - mp3_24000_48\n              - mp3_44100_32\n              - mp3_44100_64\n              - mp3_44100_96\n              - mp3_44100_128\n              - mp3_44100_192\n              - pcm_8000\n              - pcm_16000\n              - pcm_22050\n              - pcm_24000\n              - pcm_32000\n              - pcm_44100\n              - pcm_48000\n              - ulaw_8000\n              - alaw_8000\n              - opus_48000_32\n              - opus_48000_64\n              - opus_48000_96\n              - opus_48000_128\n              - opus_48000_192\n            default: mp3_44100_128\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/ElevenLabsTextToDialogueRequest\"\n      responses:\n        \"200\":\n          description: The generated audio file\n          content:\n            audio/mpeg:\n              schema:\n                type: string\n                format: binary\n            audio/wav:\n              schema:\n                type: string\n                format: binary\n            audio/ogg:\n              schema:\n                type: string\n                format: binary\n            application/octet-stream:\n              schema:\n                type: string\n                format: binary\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Error\"\n        \"401\":\n          description: Unauthorized\n        \"422\":\n          description: Validation Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ElevenLabsValidationError\"\n\n  /features:\n    get:\n      tags:\n        - Registry\n      summary: Get server feature flags\n      description: Returns the server's feature capabilities\n      operationId: getFeatures\n      responses:\n        '200':\n          description: Success\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FeaturesResponse\"\n\n  /proxy/freepik/v1/ai/image-upscaler:\n    post:\n      summary: Upscale an image with Magnific\n      description: |\n        This asynchronous endpoint enables image upscaling using advanced AI algorithms.\n        Upon submission, it returns a unique task_id which can be used to track the progress.\n        For real-time production use, include the optional webhook_url parameter to receive\n        an automated notification once the task has been completed.\n      operationId: freepikMagnificUpscalerCreative\n      tags:\n        - Freepik\n        - Proxy\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/FreepikMagnificUpscalerCreativeRequest\"\n      responses:\n        \"200\":\n          description: OK - The upscaling process has started\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikTaskResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikErrorResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikErrorResponse\"\n\n  /proxy/freepik/v1/ai/image-upscaler/{task_id}:\n    get:\n      summary: Get the status of the upscaling task\n      description: Get the status of the upscaling task\n      operationId: freepikMagnificUpscalerCreativeGetStatus\n      tags:\n        - Freepik\n        - Proxy\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: task_id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: ID of the task\n      responses:\n        \"200\":\n          description: OK - The task status is returned\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikTaskResponse\"\n        \"404\":\n          description: Task not found\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikErrorResponse\"\n\n  /proxy/freepik/v1/ai/image-upscaler-precision-v2:\n    post:\n      summary: Upscale an image with Precision V2\n      description: |\n        Upscales an image while adding new visual elements or details (V2).\n        This endpoint may modify the original image content based on the prompt and inferred context.\n        Upon submission, it returns a unique task_id which can be used to track the progress.\n      operationId: freepikMagnificUpscalerPrecisionV2\n      tags:\n        - Freepik\n        - Proxy\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/FreepikMagnificUpscalerPrecisionV2Request\"\n      responses:\n        \"200\":\n          description: OK - The upscaling process has started\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikTaskResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikErrorResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikErrorResponse\"\n\n  /proxy/freepik/v1/ai/image-upscaler-precision-v2/{task_id}:\n    get:\n      summary: Get the status of the Precision V2 upscaling task\n      description: Returns the current status and output URL of a specific precision upscaler V2 task.\n      operationId: freepikMagnificUpscalerPrecisionV2GetStatus\n      tags:\n        - Freepik\n        - Proxy\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: task_id\n          in: path\n          required: true\n          schema:\n            type: string\n            format: uuid\n          description: ID of the task\n      responses:\n        \"200\":\n          description: OK - The task status is returned\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikTaskResponse\"\n        \"404\":\n          description: Task not found\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikErrorResponse\"\n\n  /proxy/freepik/v1/ai/image-relight:\n    post:\n      summary: Relight an image\n      description: |\n        Relight an image using AI. This endpoint accepts a variety of parameters to customize the generated images.\n      operationId: freepikMagnificRelight\n      tags:\n        - Freepik\n        - Proxy\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/FreepikMagnificRelightRequest\"\n      responses:\n        \"200\":\n          description: OK - The relight process has started\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikTaskResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikErrorResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikErrorResponse\"\n\n  /proxy/freepik/v1/ai/image-relight/{task_id}:\n    get:\n      summary: Get the status of the relight task\n      description: Get the status of the relight task\n      operationId: freepikMagnificRelightGetStatus\n      tags:\n        - Freepik\n        - Proxy\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: task_id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: ID of the task\n      responses:\n        \"200\":\n          description: OK - The task status is returned\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikTaskResponse\"\n        \"404\":\n          description: Task not found\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikErrorResponse\"\n\n  /proxy/freepik/v1/ai/skin-enhancer/creative:\n    post:\n      summary: Skin enhancer using AI (Creative)\n      description: Enhance skin in images using AI with the Creative mode. This mode provides more artistic and stylized enhancements.\n      operationId: freepikSkinEnhancerCreative\n      tags:\n        - Freepik\n        - Proxy\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/FreepikSkinEnhancerCreativeRequest\"\n      responses:\n        \"200\":\n          description: OK - The skin enhancer process has started\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikTaskResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikErrorResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikErrorResponse\"\n\n  /proxy/freepik/v1/ai/skin-enhancer/flexible:\n    post:\n      summary: Skin enhancer using AI (Flexible)\n      description: Enhance skin in images using AI with the Flexible mode. This mode allows you to choose the optimization target for the enhancement.\n      operationId: freepikSkinEnhancerFlexible\n      tags:\n        - Freepik\n        - Proxy\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/FreepikSkinEnhancerFlexibleRequest\"\n      responses:\n        \"200\":\n          description: OK - The skin enhancer process has started\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikTaskResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikErrorResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikErrorResponse\"\n\n  /proxy/freepik/v1/ai/skin-enhancer/faithful:\n    post:\n      summary: Skin enhancer using AI (Faithful)\n      description: Enhance skin in images using AI with the Faithful mode. This mode preserves the original appearance while improving skin quality.\n      operationId: freepikSkinEnhancerFaithful\n      tags:\n        - Freepik\n        - Proxy\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/FreepikSkinEnhancerFaithfulRequest\"\n      responses:\n        \"200\":\n          description: OK - The skin enhancer process has started\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikTaskResponse\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikErrorResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikErrorResponse\"\n\n  /proxy/freepik/v1/ai/skin-enhancer/{task_id}:\n    get:\n      summary: Get the status of one skin enhancer task\n      description: Get the status of a skin enhancer task (works for both Creative and Faithful modes)\n      operationId: freepikSkinEnhancerGetStatus\n      tags:\n        - Freepik\n        - Proxy\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: task_id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: ID of the task\n      responses:\n        \"200\":\n          description: OK - The task status is returned\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikTaskResponse\"\n        \"404\":\n          description: Task not found\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikErrorResponse\"\n\n  /proxy/freepik/v1/ai/image-style-transfer:\n    post:\n      summary: Style transfer an image\n      description: Style transfer an image using AI.\n      operationId: freepikMagnificStyleTransfer\n      tags:\n        - Freepik\n        - Proxy\n      security:\n        - BearerAuth: []\n      requestBody:\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/FreepikMagnificStyleTransferRequest\"\n      responses:\n        \"200\":\n          description: OK - The style transfer process has started\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikTaskData\"\n        \"400\":\n          description: Bad Request\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikErrorResponse\"\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikErrorResponse\"\n\n  /proxy/freepik/v1/ai/image-style-transfer/{task_id}:\n    get:\n      summary: Get the status of the style transfer task\n      description: Get the status of the style transfer task\n      operationId: freepikMagnificStyleTransferGetStatus\n      tags:\n        - Freepik\n        - Proxy\n      security:\n        - BearerAuth: []\n      parameters:\n        - name: task_id\n          in: path\n          required: true\n          schema:\n            type: string\n          description: ID of the task\n      responses:\n        \"200\":\n          description: OK - The task status is returned\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikTaskResponse\"\n        \"404\":\n          description: Task not found\n        \"500\":\n          description: Internal Server Error\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/FreepikErrorResponse\"\n\ncomponents:\n  schemas:\n    FreepikMagnificUpscalerCreativeRequest:\n      type: object\n      required:\n        - image\n      properties:\n        image:\n          type: string\n          description: Base64 image or URL to upscale. The resulted image can't exceed maximum allowed size of 25.3 million pixels.\n        webhook_url:\n          type: string\n          format: uri\n          description: Optional callback URL that will receive asynchronous notifications whenever the task changes status.\n          example: \"https://www.example.com/webhook\"\n        scale_factor:\n          type: string\n          enum: [\"2x\", \"4x\", \"8x\", \"16x\"]\n          default: \"2x\"\n          description: Configure scale factor of the image. For higher scales, the image will take longer to process.\n        optimized_for:\n          type: string\n          enum: [standard, soft_portraits, hard_portraits, art_n_illustration, videogame_assets, nature_n_landscapes, films_n_photography, 3d_renders, science_fiction_n_horror]\n          default: standard\n          description: Styles to optimize the upscale process.\n        prompt:\n          type: string\n          description: Prompt to guide the upscale process. Reusing the same prompt for AI-generated images will improve the results.\n        creativity:\n          type: integer\n          minimum: -10\n          maximum: 10\n          default: 0\n          description: Increase or decrease AI's creativity. Valid values range [-10, 10].\n        hdr:\n          type: integer\n          minimum: -10\n          maximum: 10\n          default: 0\n          description: Increase or decrease the level of definition and detail. Valid values range [-10, 10].\n        resemblance:\n          type: integer\n          minimum: -10\n          maximum: 10\n          default: 0\n          description: Adjust the level of resemblance to the original image. Valid values range [-10, 10].\n        fractality:\n          type: integer\n          minimum: -10\n          maximum: 10\n          default: 0\n          description: Control the strength of the prompt and intricacy per square pixel. Valid values range [-10, 10].\n        engine:\n          type: string\n          enum: [automatic, magnific_illusio, magnific_sharpy, magnific_sparkle]\n          default: automatic\n          description: Magnific model engines.\n\n    FreepikTaskResponse:\n      type: object\n      required:\n        - data\n      properties:\n        data:\n          $ref: \"#/components/schemas/FreepikTaskData\"\n    FreepikTaskData:\n      type: object\n      properties:\n        task_id:\n          type: string\n          format: uuid\n          example: \"046b6c7f-0b8a-43b9-b35d-6489e6daee91\"\n        status:\n          type: string\n          enum: [CREATED, IN_PROGRESS, COMPLETED, FAILED]\n        generated:\n          type: array\n          items:\n            type: string\n            format: uri\n          description: URLs to the generated images.\n    FreepikErrorResponse:\n      type: object\n      properties:\n        error:\n          type: string\n        message:\n          type: string\n\n    FreepikSkinEnhancerFlexibleRequest:\n      type: object\n      required:\n        - image\n      properties:\n        image:\n          type: string\n          description: Input image. Supports Base64 encoding or HTTPS URL (must be publicly accessible).\n          example: \"https://example.com/portrait.jpg\"\n        sharpen:\n          type: integer\n          minimum: 0\n          maximum: 100\n          default: 0\n          description: Sharpening intensity\n        smart_grain:\n          type: integer\n          minimum: 0\n          maximum: 100\n          default: 2\n          description: Smart grain intensity\n        optimized_for:\n          type: string\n          enum: [enhance_skin, improve_lighting, enhance_everything, transform_to_real, no_make_up]\n          default: enhance_skin\n          description: Optimization target for flexible skin enhancer\n        webhook_url:\n          type: string\n          format: uri\n          description: Optional callback URL for async notifications.\n          example: \"https://www.example.com/webhook\"\n\n    FreepikSkinEnhancerFaithfulRequest:\n      type: object\n      required:\n        - image\n      properties:\n        image:\n          type: string\n          description: Input image. Supports Base64 encoding or HTTPS URL (must be publicly accessible).\n          example: \"https://example.com/portrait.jpg\"\n        sharpen:\n          type: integer\n          minimum: 0\n          maximum: 100\n          default: 0\n          description: Sharpening intensity\n        smart_grain:\n          type: integer\n          minimum: 0\n          maximum: 100\n          default: 2\n          description: Smart grain intensity\n        skin_detail:\n          type: integer\n          minimum: 0\n          maximum: 100\n          default: 80\n          description: Skin detail enhancement level\n        webhook_url:\n          type: string\n          format: uri\n          description: Optional callback URL for async notifications.\n          example: \"https://www.example.com/webhook\"\n\n    FreepikSkinEnhancerCreativeRequest:\n      type: object\n      required:\n        - image\n      properties:\n        image:\n          type: string\n          description: Input image. Supports Base64 encoding or HTTPS URL (must be publicly accessible).\n          example: \"https://example.com/portrait.jpg\"\n        sharpen:\n          type: integer\n          minimum: 0\n          maximum: 100\n          default: 0\n          description: Sharpening intensity\n        smart_grain:\n          type: integer\n          minimum: 0\n          maximum: 100\n          default: 2\n          description: Smart grain intensity\n        webhook_url:\n          type: string\n          format: uri\n          description: Optional callback URL for async notifications.\n          example: \"https://www.example.com/webhook\"\n\n    FreepikMagnificStyleTransferRequest:\n      type: object\n      required:\n        - image\n        - reference_image\n      properties:\n        image:\n          type: string\n          description: Base64 or URL of the image to do the style transfer\n        reference_image:\n          type: string\n          description: Base64 or URL of the reference image for style transfer\n        webhook_url:\n          type: string\n          format: uri\n          description: Optional callback URL for async notifications.\n          example: \"https://www.example.com/webhook\"\n        prompt:\n          type: string\n          description: Prompt for the AI model\n        style_strength:\n          type: integer\n          minimum: 0\n          maximum: 100\n          default: 100\n          description: Percentage of style strength\n        structure_strength:\n          type: integer\n          minimum: 0\n          maximum: 100\n          default: 50\n          description: Allows to maintain the structure of the original image\n        is_portrait:\n          type: boolean\n          default: false\n          description: Indicates whether the image should be processed as a portrait.\n        portrait_style:\n          type: string\n          enum: [standard, pop, super_pop]\n          default: standard\n          description: Visual style applied to portrait images. Only used if is_portrait is true.\n        portrait_beautifier:\n          type: string\n          enum: [beautify_face, beautify_face_max]\n          description: Facial beautification on portrait images. Only used if is_portrait is true.\n        flavor:\n          type: string\n          enum: [faithful, gen_z, psychedelia, detaily, clear, donotstyle, donotstyle_sharp]\n          default: faithful\n          description: Flavor of the transferring style\n        engine:\n          type: string\n          enum: [balanced, definio, illusio, 3d_cartoon, colorful_anime, caricature, real, super_real, softy]\n          default: balanced\n          description: Engine preset for style transfer\n        fixed_generation:\n          type: boolean\n          default: false\n          description: When enabled, using the same settings will consistently produce the same image.\n\n    FreepikMagnificRelightRequest:\n      type: object\n      required:\n        - image\n      properties:\n        image:\n          type: string\n          description: Base64 or URL of the image to do the relight\n        webhook_url:\n          type: string\n          format: uri\n          description: Optional callback URL that will receive asynchronous notifications whenever the task changes status.\n          example: \"https://www.example.com/webhook\"\n        prompt:\n          type: string\n          description: |\n            You can guide the generation process and influence the light transfer with a descriptive prompt.\n            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)\".\n        transfer_light_from_reference_image:\n          type: string\n          description: Base64 or URL of the reference image for light transfer. Incompatible with 'transfer_light_from_lightmap'\n        transfer_light_from_lightmap:\n          type: string\n          description: Base64 or URL of the lightmap for light transfer. Incompatible with 'transfer_light_from_reference_image'\n        light_transfer_strength:\n          type: integer\n          minimum: 0\n          maximum: 100\n          default: 100\n          description: Level of light transfer intensity. 0% keeps closest to original, 100% is maximum transfer.\n        interpolate_from_original:\n          type: boolean\n          default: false\n          description: When enabled, makes the final image interpolate from the original using the light transfer strength slider.\n        change_background:\n          type: boolean\n          default: true\n          description: When enabled, changes the background based on prompt and/or reference image. Useful for product placement and portraits.\n        style:\n          type: string\n          enum: [standard, darker_but_realistic, clean, smooth, brighter, contrasted_n_hdr, just_composition]\n          default: standard\n          description: Style preset for the relight operation.\n        preserve_details:\n          type: boolean\n          default: true\n          description: Maintains texture and small details of the original image. Good for product photography, texts, etc.\n        advanced_settings:\n          type: object\n          properties:\n            whites:\n              type: integer\n              minimum: 0\n              maximum: 100\n              default: 50\n              description: Adjust the level of white color in the image.\n            blacks:\n              type: integer\n              minimum: 0\n              maximum: 100\n              default: 50\n              description: Adjust the level of black color in the image.\n            brightness:\n              type: integer\n              minimum: 0\n              maximum: 100\n              default: 50\n              description: Adjust the level of brightness in the image.\n            contrast:\n              type: integer\n              minimum: 0\n              maximum: 100\n              default: 50\n              description: Adjust the level of contrast in the image.\n            saturation:\n              type: integer\n              minimum: 0\n              maximum: 100\n              default: 50\n              description: Adjust the level of saturation in the image.\n            engine:\n              type: string\n              enum: [automatic, balanced, cool, real, illusio, fairy, colorful_anime, hard_transform, softy]\n              default: automatic\n              description: |\n                Engine preset for relighting:\n                - balanced: Well-rounded, general-purpose option\n                - cool: Brighter with cooler tones\n                - real: Aims to enhance photographic quality (Experimental)\n                - illusio: Optimized for illustrations and drawings\n                - fairy: Suited for fantasy-themed images\n                - colorful_anime: Ideal for anime, cartoons, and vibrant colors\n                - hard_transform: Significantly alters the original image\n                - softy: Slightly softer effect, suitable for graphic designs\n            transfer_light_a:\n              type: string\n              enum: [automatic, low, medium, normal, high, high_on_faces]\n              default: automatic\n              description: Adjusts the intensity of light transfer.\n            transfer_light_b:\n              type: string\n              enum: [automatic, composition, straight, smooth_in, smooth_out, smooth_both, reverse_both, soft_in, soft_out, soft_mid, strong_mid, style_shift, strong_shift]\n              default: automatic\n              description: Also modifies light transfer intensity. Can be combined with transfer_light_a for varied effects.\n            fixed_generation:\n              type: boolean\n              default: false\n              description: When enabled, using the same settings will consistently produce the same image.\n\n    FreepikMagnificUpscalerPrecisionV2Request:\n      type: object\n      required:\n        - image\n      properties:\n        image:\n          type: string\n          description: |\n            Source image to upscale. Accepts either:\n            - A publicly accessible HTTPS URL pointing to the image\n            - A base64-encoded image string\n        webhook_url:\n          type: string\n          format: uri\n          description: Optional callback URL that will receive asynchronous notifications when the upscaling task completes.\n        sharpen:\n          type: integer\n          minimum: 0\n          maximum: 100\n          default: 7\n          description: Image sharpness intensity control. Higher values increase edge definition and clarity.\n        smart_grain:\n          type: integer\n          minimum: 0\n          maximum: 100\n          default: 7\n          description: Intelligent grain/texture enhancement. Higher values add more fine-grained texture.\n        ultra_detail:\n          type: integer\n          minimum: 0\n          maximum: 100\n          default: 30\n          description: Ultra detail enhancement level. Higher values create more intricate details.\n        flavor:\n          type: string\n          enum: [sublime, photo, photo_denoiser]\n          description: |\n            Image processing flavor:\n            - sublime: Optimized for artistic and illustrated images\n            - photo: Optimized for photographic images\n            - photo_denoiser: Specialized for photos with noise reduction\n        scale_factor:\n          type: integer\n          minimum: 2\n          maximum: 16\n          description: Image scaling factor. Determines how much larger the output will be compared to input.\n\n    SubscriptionTier:\n      type: string\n      description: The subscription tier level\n      enum:\n        - FREE\n        - STANDARD\n        - CREATOR\n        - PRO\n        - FOUNDERS_EDITION\n    SubscriptionDuration:\n      type: string\n      description: The subscription billing duration\n      enum:\n        - MONTHLY\n        - ANNUAL\n    FeaturesResponse:\n      type: object\n      properties:\n        partner_node_conversion_rate:\n          type: number\n          description: The conversion rate for partner nodes\n          example: 0.5\n      required:\n        - partner_node_conversion_rate\n    ClaimMyNodeRequest:\n      type: object\n      properties:\n        GH_TOKEN:\n          type: string\n          description: GitHub token to verify if the user owns the repo of the node\n      required:\n        - GH_TOKEN\n    BulkNodeVersionsRequest:\n      type: object\n      properties:\n        node_versions:\n          type: array\n          items:\n            $ref: \"#/components/schemas/NodeVersionIdentifier\"\n          description: List of node ID and version pairs to retrieve\n      required:\n        - node_versions\n    NodeVersionIdentifier:\n      type: object\n      properties:\n        node_id:\n          type: string\n          description: The unique identifier of the node\n        version:\n          type: string\n          description: The version of the node\n      required:\n        - node_id\n        - version\n    BulkNodeVersionsResponse:\n      type: object\n      properties:\n        node_versions:\n          type: array\n          items:\n            $ref: \"#/components/schemas/BulkNodeVersionResult\"\n          description: List of retrieved node versions with their status\n      required:\n        - node_versions\n    BulkNodeVersionResult:\n      type: object\n      properties:\n        identifier:\n          $ref: \"#/components/schemas/NodeVersionIdentifier\"\n          description: The node and version identifier\n        status:\n          type: string\n          enum: [success, not_found, error]\n          description: Status of the retrieval operation\n        node_version:\n          $ref: \"#/components/schemas/NodeVersion\"\n          description: The retrieved node version data (only present if status is success)\n        error_message:\n          type: string\n          description: Error message if retrieval failed (only present if status is error)\n      required:\n        - identifier\n        - status\n    PersonalAccessToken:\n      type: object\n      properties:\n        id:\n          type: string\n          format: uuid\n          description: Unique identifier for the GitCommit\n        name:\n          type: string\n          description: Required. The name of the token. Can be a simple description.\n        description:\n          type: string\n          description: Optional. A more detailed description of the token's intended use.\n        createdAt:\n          type: string\n          format: date-time\n          description: \"[Output Only]The date and time the token was created.\"\n        token:\n          type: string\n          description: \"[Output Only]. The personal access token. Only returned during creation.\"\n    GitCommit:\n      type: object\n      properties:\n        id:\n          type: string\n          format: uuid\n          description: Unique identifier for the GitCommit\n        commit_hash:\n          type: string\n          description: The hash of the commit\n        commit_name:\n          type: string\n          description: The name of the commit\n        branch_name:\n          type: string\n          description: The branch where the commit was made\n        author:\n          type: string\n          description: The author of the commit\n        timestamp:\n          type: string\n          format: date-time\n          description: The timestamp when the commit was made\n    GitCommitSummary:\n      type: object\n      properties:\n        commit_hash:\n          type: string\n          description: The hash of the commit\n        commit_name:\n          type: string\n          description: The name of the commit\n        branch_name:\n          type: string\n          description: The branch where the commit was made\n        author:\n          type: string\n          description: The author of the commit\n        timestamp:\n          type: string\n          format: date-time\n          description: The timestamp when the commit was made\n        status_summary:\n          type: object\n          description: A map of operating system to status pairs\n          additionalProperties:\n            type: string\n    User:\n      type: object\n      properties:\n        id:\n          type: string\n          description: The unique id for this user.\n        email:\n          type: string\n          description: The email address for this user.\n        name:\n          type: string\n          description: The name for this user.\n        isApproved:\n          type: boolean\n          description: Indicates if the user is approved.\n        isAdmin:\n          type: boolean\n          description: Indicates if the user has admin privileges.\n    PublisherUser:\n      type: object\n      properties:\n        id:\n          type: string\n          description: The unique id for this user.\n        email:\n          type: string\n          description: The email address for this user.\n        name:\n          type: string\n          description: The name for this user.\n    ErrorResponse:\n      type: object\n      properties:\n        error:\n          type: string\n        message:\n          type: string\n      required:\n        - error\n        - message\n    CreateCouponRequest:\n      type: object\n      properties:\n        name:\n          type: string\n          description: Name of the coupon displayed to customers\n        percent_off:\n          type: number\n          format: double\n          description: Percent off discount (0-100)\n          minimum: 0\n          maximum: 100\n        amount_off:\n          type: integer\n          description: Amount off in cents\n          minimum: 0\n        currency:\n          type: string\n          description: Currency for amount_off (required if amount_off is set)\n          enum: [usd]\n        duration:\n          type: string\n          description: How long the coupon lasts\n          enum: [once, repeating, forever]\n          default: once\n        duration_in_months:\n          type: integer\n          description: Required if duration is repeating\n          minimum: 1\n        max_redemptions:\n          type: integer\n          description: Maximum number of times this coupon can be redeemed\n          minimum: 1\n        redeem_by:\n          type: integer\n          format: int64\n          description: Unix timestamp specifying the last time at which the coupon can be redeemed\n        metadata:\n          type: object\n          additionalProperties:\n            type: string\n          description: Set of key-value pairs for storing additional information\n    UpdateCouponRequest:\n      type: object\n      properties:\n        name:\n          type: string\n          description: Name of the coupon displayed to customers\n        metadata:\n          type: object\n          additionalProperties:\n            type: string\n          description: Set of key-value pairs for storing additional information\n    CouponResponse:\n      type: object\n      properties:\n        id:\n          type: string\n          description: The Stripe coupon ID\n        name:\n          type: string\n          description: Name of the coupon displayed to customers\n        percent_off:\n          type: number\n          format: double\n          description: Percent off discount (0-100)\n        amount_off:\n          type: integer\n          description: Amount off in cents\n        currency:\n          type: string\n          description: Currency for amount_off\n        duration:\n          type: string\n          description: How long the coupon lasts\n          enum: [once, repeating, forever]\n        duration_in_months:\n          type: integer\n          description: Number of months for repeating coupons\n        max_redemptions:\n          type: integer\n          description: Maximum number of times this coupon can be redeemed\n        times_redeemed:\n          type: integer\n          description: Number of times this coupon has been redeemed\n        redeem_by:\n          type: integer\n          format: int64\n          description: Unix timestamp specifying the last time at which the coupon can be redeemed\n        valid:\n          type: boolean\n          description: Whether the coupon can still be redeemed\n        metadata:\n          type: object\n          additionalProperties:\n            type: string\n          description: Set of key-value pairs for storing additional information\n      required:\n        - id\n        - duration\n        - valid\n    CreatePromoCodeRequest:\n      type: object\n      properties:\n        coupon_id:\n          type: string\n          description: The Stripe coupon ID to create the promotional code for\n        expire_days:\n          type: integer\n          description: Number of days until the promotion code expires\n          minimum: 1\n          default: 30\n        max_redemptions:\n          type: integer\n          description: Maximum number of times this code can be redeemed\n          minimum: 1\n      required:\n        - coupon_id\n    UpdatePromoCodeRequest:\n      type: object\n      properties:\n        active:\n          type: boolean\n          description: Whether the promo code is active\n        metadata:\n          type: object\n          additionalProperties:\n            type: string\n          description: Set of key-value pairs for storing additional information\n    PromoCodeResponse:\n      type: object\n      properties:\n        id:\n          type: string\n          description: The Stripe promotion code ID\n        code:\n          type: string\n          description: The generated promotional code\n        coupon_id:\n          type: string\n          description: The Stripe coupon ID associated with this promo code\n        active:\n          type: boolean\n          description: Whether the promo code is currently active\n        expires_at:\n          type: integer\n          format: int64\n          description: Unix timestamp when the promo code expires\n        max_redemptions:\n          type: integer\n          description: Maximum number of times this code can be redeemed\n        times_redeemed:\n          type: integer\n          description: Number of times this code has been redeemed\n        metadata:\n          type: object\n          additionalProperties:\n            type: string\n          description: Set of key-value pairs for storing additional information\n      required:\n        - id\n        - code\n        - coupon_id\n        - active\n    RunwayTextToImageRequest:\n      type: object\n      properties:\n        promptText:\n          type: string\n          maxLength: 1000\n          description: Text prompt for the image generation\n        model:\n          type: string\n          enum: [gen4_image]\n          description: Model to use for generation\n        ratio:\n          $ref: \"#/components/schemas/RunwayTextToImageAspectRatioEnum\"\n          description: The resolution (aspect ratio) of the output image\n        referenceImages:\n          type: array\n          items:\n            type: object\n            properties:\n              uri:\n                type: string\n                description: A HTTPS URL or data URI containing an encoded image\n          description: Array of reference images to guide the generation\n      required:\n        - promptText\n        - model\n        - ratio\n    ActionJobResult:\n      type: object\n      properties:\n        id:\n          type: string\n          format: uuid\n          description: Unique identifier for the job result\n        workflow_name:\n          type: string\n          description: Name of the workflow\n        operating_system:\n          type: string\n          description: Operating system used\n        python_version:\n          type: string\n          description: PyTorch version used\n        pytorch_version:\n          type: string\n          description: PyTorch version used\n        action_run_id:\n          type: string\n          description: Identifier of the run this result belongs to\n        action_job_id:\n          type: string\n          description: Identifier of the job this result belongs to\n        cuda_version:\n          type: string\n          description: CUDA version used\n        branch_name:\n          type: string\n          description: Name of the relevant git branch\n        commit_hash:\n          type: string\n          description: The hash of the commit\n        commit_id:\n          type: string\n          description: The ID of the commit\n        commit_time:\n          type: integer\n          format: int64\n          description: The Unix timestamp when the commit was made\n        commit_message:\n          type: string\n          description: The message of the commit\n        comfy_run_flags:\n          type: string\n          description: The comfy run flags. E.g. `--low-vram`\n        git_repo:\n          type: string\n          description: The repository name\n        pr_number:\n          type: string\n          description: The pull request number\n        start_time:\n          type: integer\n          format: int64\n          description: The start time of the job as a Unix timestamp.\n        end_time:\n          type: integer\n          format: int64\n          description: The end time of the job as a Unix timestamp.\n        avg_vram:\n          type: integer\n          description: The average VRAM used by the job\n        peak_vram:\n          type: integer\n          description: The peak VRAM used by the job\n        job_trigger_user:\n          type: string\n          description: The user who triggered the job.\n        author:\n          type: string\n          description: The author of the commit\n        machine_stats:\n          $ref: \"#/components/schemas/MachineStats\"\n        status:\n          $ref: \"#/components/schemas/WorkflowRunStatus\"\n        storage_file:\n          $ref: \"#/components/schemas/StorageFile\"\n    StorageFile:\n      type: object\n      properties:\n        id:\n          type: string\n          format: uuid\n          description: Unique identifier for the storage file\n        file_path:\n          type: string\n          description: Path to the file in storage\n        public_url:\n          type: string\n          description: Public URL\n    Publisher:\n      type: object\n      properties:\n        name:\n          type: string\n        id:\n          type: string\n          description: The unique identifier for the publisher. It's akin to a username. Should be lowercase.\n        description:\n          type: string\n        website:\n          type: string\n        support:\n          type: string\n        source_code_repo:\n          type: string\n        logo:\n          type: string\n          description: URL to the publisher's logo.\n        createdAt:\n          type: string\n          format: date-time\n          description: The date and time the publisher was created.\n        members:\n          type: array\n          items:\n            $ref: \"#/components/schemas/PublisherMember\"\n          description: A list of members in the publisher.\n        status:\n          $ref: \"#/components/schemas/PublisherStatus\"\n          description: The status of the publisher.\n    PublisherMember:\n      type: object\n      properties:\n        id:\n          type: string\n          description: The unique identifier for the publisher member.\n        user:\n          $ref: \"#/components/schemas/PublisherUser\"\n          description: The user associated with this publisher member.\n        role:\n          type: string\n          description: The role of the user in the publisher.\n    Node:\n      type: object\n      properties:\n        id:\n          type: string\n          description: \"The unique identifier of the node.\"\n        name:\n          type: string\n          description: The display name of the node.\n        category:\n          type: string\n          description: \"DEPRECATED: The category of the node. Use 'tags' field instead. This field will be removed in a future version.\"\n          deprecated: true\n        description:\n          type: string\n        author:\n          type: string\n        license:\n          type: string\n          description: The path to the LICENSE file in the node's repository.\n        icon:\n          type: string\n          description: URL to the node's icon.\n        repository:\n          type: string\n          description: URL to the node's repository.\n        tags:\n          type: array\n          items:\n            type: string\n        tags_admin:\n          type: array\n          items:\n            type: string\n          description: Admin-only tags for security warnings and admin metadata\n        supported_os:\n          type: array\n          items:\n            type: string\n          description: List of operating systems that this node supports\n        supported_accelerators:\n          type: array\n          items:\n            type: string\n          description: List of accelerators (e.g. CUDA, DirectML, ROCm) that this node supports\n        supported_comfyui_version:\n          type: string\n          description: Supported versions of ComfyUI\n        supported_comfyui_frontend_version:\n          type: string\n          description: Supported versions of ComfyUI frontend\n        latest_version:\n          $ref: \"#/components/schemas/NodeVersion\"\n          description: The latest version of the node.\n        rating:\n          type: number\n          description: The average rating of the node.\n        downloads:\n          type: integer\n          description: The number of downloads of the node.\n        publisher:\n          $ref: \"#/components/schemas/Publisher\"\n          description: The publisher of the node.\n        status:\n          $ref: \"#/components/schemas/NodeStatus\"\n          description: The status of the node.\n        status_detail:\n          type: string\n          description: The status detail of the node.\n        translations:\n          type: object\n          additionalProperties:\n            type: object\n            additionalProperties: true\n          description: Translations of node metadata in different languages.\n        search_ranking:\n          type: integer\n          description: A numerical value representing the node's search ranking, used for sorting search results.\n        preempted_comfy_node_names:\n          type: array\n          items:\n            type: string\n          description: A list of Comfy node names that are preempted by this node.\n        banner_url:\n          type: string\n          description: URL to the node's banner.\n        github_stars:\n          type: integer\n          description: Number of stars on the GitHub repository.\n        created_at:\n          type: string\n          format: date-time\n          description: The date and time when the node was created\n    NodeVersion:\n      type: object\n      properties:\n        id:\n          type: string\n        version:\n          type: string\n          description: The version identifier, following semantic versioning. Must be unique for the node.\n        createdAt:\n          type: string\n          format: date-time\n          description: The date and time the version was created.\n        changelog:\n          type: string\n          description: Summary of changes made in this version\n        dependencies:\n          type: array\n          items:\n            type: string\n          description: A list of pip dependencies required by the node.\n        downloadUrl:\n          type: string\n          description: \"[Output Only] URL to download this version of the node\"\n        deprecated:\n          type: boolean\n          description: Indicates if this version is deprecated.\n        status:\n          $ref: \"#/components/schemas/NodeVersionStatus\"\n          description: The status of the node version.\n        status_reason:\n          type: string\n        tags:\n          type: array\n          items:\n            type: string\n        tags_admin:\n          type: array\n          items:\n            type: string\n          description: Admin-only tags for security warnings and admin metadata\n        node_id:\n          type: string\n          description: The unique identifier of the node.\n        comfy_node_extract_status:\n          type: string\n          description: The status of comfy node extraction process.\n        supported_comfyui_version:\n          type: string\n          description: Supported versions of ComfyUI\n        supported_comfyui_frontend_version:\n          type: string\n          description: Supported versions of ComfyUI frontend\n        supported_os:\n          type: array\n          items:\n            type: string\n          description: List of operating systems that this node supports\n        supported_accelerators:\n          type: array\n          items:\n            type: string\n          description: List of accelerators (e.g. CUDA, DirectML, ROCm) that this node supports\n    ComfyNode:\n      type: object\n      properties:\n        comfy_node_name:\n          type: string\n          description: Unique identifier for the node\n        category:\n          type: string\n          description: UI category where the node is listed, used for grouping nodes.\n        description:\n          type: string\n          description: Brief description of the node's functionality or purpose.\n        input_types:\n          type: string\n          description: Defines input parameters\n        deprecated:\n          type: boolean\n          description: Indicates if the node is deprecated. Deprecated nodes are hidden in the UI.\n        experimental:\n          type: boolean\n          description: Indicates if the node is experimental, subject to changes or removal.\n        output_is_list:\n          type: array\n          items:\n            type: boolean\n          description: Boolean values indicating if each output is a list.\n        return_names:\n          type: string\n          description: Names of the outputs for clarity in workflows.\n        return_types:\n          type: string\n          description: Specifies the types of outputs produced by the node.\n        function:\n          type: string\n          description: Name of the entry-point function to execute the node.\n        policy:\n          $ref: \"#/components/schemas/ComfyNodePolicy\"\n          description: The policy associated with the comfy node.\n    ComfyNodeCloudBuildInfo:\n      type: object\n      properties:\n        project_id:\n          type: string\n        project_number:\n          type: string\n        location:\n          type: string\n        build_id:\n          type: string\n    Error:\n      type: object\n      properties:\n        message:\n          type: string\n          description: A clear and concise description of the error.\n        details:\n          type: array\n          items:\n            type: string\n          description: Optional detailed information about the error or hints for resolving it.\n    # ======= Request body Definitions =======================\n    NodeVersionUpdateRequest:\n      type: object\n      properties:\n        changelog:\n          type: string\n          description: The changelog describing the version changes.\n        deprecated:\n          type: boolean\n          description: Whether the version is deprecated.\n    # Enum of Node Status\n    NodeStatus:\n      type: string\n      enum:\n        - NodeStatusActive\n        - NodeStatusDeleted\n        - NodeStatusBanned\n    # Enum of Comfy Node Policy\n    ComfyNodePolicy:\n      type: string\n      enum:\n        - ComfyNodePolicyActive\n        - ComfyNodePolicyBanned\n        - ComfyNodePolicyLocalOnly\n    ComfyNodeUpdateRequest:\n      type: object\n      properties:\n        category:\n          type: string\n          description: UI category where the node is listed, used for grouping nodes.\n        description:\n          type: string\n          description: Brief description of the node's functionality or purpose.\n        input_types:\n          type: string\n          description: Defines input parameters\n        deprecated:\n          type: boolean\n          description: Indicates if the node is deprecated. Deprecated nodes are hidden in the UI.\n        experimental:\n          type: boolean\n          description: Indicates if the node is experimental, subject to changes or removal.\n        output_is_list:\n          type: array\n          items:\n            type: boolean\n          description: Boolean values indicating if each output is a list.\n        return_names:\n          type: string\n          description: Names of the outputs for clarity in workflows.\n        return_types:\n          type: string\n          description: Specifies the types of outputs produced by the node.\n        function:\n          type: string\n          description: Name of the entry-point function to execute the node.\n        policy:\n          $ref: \"#/components/schemas/ComfyNodePolicy\"\n          description: The policy associated with the comfy node.\n    # Enum of Node Version Status\n    NodeVersionStatus:\n      type: string\n      enum:\n        - NodeVersionStatusActive\n        - NodeVersionStatusDeleted\n        - NodeVersionStatusBanned\n        - NodeVersionStatusPending\n        - NodeVersionStatusFlagged\n    PublisherStatus:\n      type: string\n      enum:\n        - PublisherStatusActive\n        - PublisherStatusBanned\n    WorkflowRunStatus:\n      type: string\n      enum:\n        - WorkflowRunStatusStarted\n        - WorkflowRunStatusFailed\n        - WorkflowRunStatusCompleted\n    MachineStats:\n      type: object\n      properties:\n        machine_name:\n          type: string\n          description: Name of the machine.\n        os_version:\n          type: string\n          description: The operating system version. eg. Ubuntu Linux 20.04\n        gpu_type:\n          type: string\n          description: The GPU type. eg. NVIDIA Tesla K80\n        cpu_capacity:\n          type: string\n          description: Total CPU on the machine.\n        initial_cpu:\n          type: string\n          description: Initial CPU available before the job starts.\n        memory_capacity:\n          type: string\n          description: Total memory on the machine.\n        initial_ram:\n          type: string\n          description: Initial RAM available before the job starts.\n        vram_time_series:\n          type: object\n          description: Time series of VRAM usage.\n        disk_capacity:\n          type: string\n          description: Total disk capacity on the machine.\n        initial_disk:\n          type: string\n          description: Initial disk available before the job starts.\n        pip_freeze:\n          type: string\n          description: The pip freeze output\n    Customer:\n      type: object\n      properties:\n        id:\n          type: string\n          description: The firebase UID of the user\n        email:\n          type: string\n          description: The email address for this user\n        name:\n          type: string\n          description: The name for this user\n        createdAt:\n          type: string\n          format: date-time\n          description: The date and time the user was created\n        updatedAt:\n          type: string\n          format: date-time\n          description: The date and time the user was last updated\n        is_admin:\n          type: boolean\n          description: Whether the user is an admin\n        stripe_id:\n          type: string\n          description: The Stripe customer ID\n        metronome_id:\n          type: string\n          description: The Metronome customer ID\n        has_fund:\n          type: boolean\n          description: Whether the user has funds\n        subscription_tier:\n          allOf:\n            - $ref: \"#/components/schemas/SubscriptionTier\"\n          nullable: true\n          description: The cached subscription tier level\n      required:\n        - id\n    CustomerAdmin:\n      type: object\n      properties:\n        id:\n          type: string\n          description: The firebase UID of the user\n        email:\n          type: string\n          description: The email address for this user\n        name:\n          type: string\n          description: The name for this user\n        createdAt:\n          type: string\n          format: date-time\n          description: The date and time the user was created\n        updatedAt:\n          type: string\n          format: date-time\n          description: The date and time the user was last updated\n        is_admin:\n          type: boolean\n          description: Whether the user is an admin\n        stripe_id:\n          type: string\n          description: The Stripe customer ID\n        metronome_id:\n          type: string\n          description: The Metronome customer ID\n        has_fund:\n          type: boolean\n          description: Whether the user has funds\n        cloud_subscription_is_active:\n          type: boolean\n          description: Whether the customer has an active cloud subscription\n        cloud_subscription_subscription_id:\n          type: string\n          description: The active subscription ID if one exists\n          nullable: true\n        cloud_subscription_renewal_date:\n          type: string\n          format: date-time\n          description: The next renewal date for the subscription (ISO 8601 format)\n          nullable: true\n        cloud_subscription_end_date:\n          type: string\n          format: date-time\n          description: The date when the subscription is set to end (ISO 8601 format)\n          nullable: true\n        subscription_tier:\n          allOf:\n            - $ref: \"#/components/schemas/SubscriptionTier\"\n          nullable: true\n          description: The subscription tier level (e.g. FREE, STANDARD, CREATOR, PRO)\n      required:\n        - id\n    AuditLog:\n      type: object\n      properties:\n        event_type:\n          type: string\n          description: the type of the event\n        event_id:\n          type: string\n          description: the id of the event\n        params:\n          type: object\n          description: data related to the event\n          additionalProperties: true\n        createdAt:\n          type: string\n          format: date-time\n          description: The date and time the event was created\n    IdeogramV3Request:\n      type: object\n      properties:\n        prompt:\n          type: string\n          description: The text prompt for image generation\n        seed:\n          type: integer\n          description: Seed value for reproducible generation\n        resolution:\n          type: string\n          description: Image resolution in format WxH\n          example: \"1280x800\"\n        aspect_ratio:\n          type: string\n          description: Aspect ratio in format WxH\n          example: \"1x3\"\n        rendering_speed:\n          $ref: \"#/components/schemas/RenderingSpeed\"\n        magic_prompt:\n          type: string\n          enum: [\"ON\", \"OFF\"]\n          description: Whether to enable magic prompt enhancement\n        negative_prompt:\n          type: string\n          description: Text prompt specifying what to avoid in the generation\n        num_images:\n          type: integer\n          description: Number of images to generate\n          minimum: 1\n        color_palette:\n          type: object\n          properties:\n            name:\n              type: string\n              description: Name of the color palette\n              example: \"PASTEL\"\n          required:\n            - name\n        style_codes:\n          type: array\n          items:\n            type: string\n            pattern: \"^[0-9A-Fa-f]{8}$\"\n          description: Array of style codes in hexadecimal format\n        style_type:\n          $ref: \"#/components/schemas/IdeogramStyleType\"\n        style_reference_images:\n          type: array\n          items:\n            type: string\n            format: binary\n          description: Array of reference image URLs or identifiers\n        character_reference_images:\n          type: array\n          items:\n            type: string\n            format: binary\n          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.\n        character_reference_images_mask:\n          type: array\n          items:\n            type: string\n            format: binary\n          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.\n      required:\n        - prompt\n        - rendering_speed\n\n    IdeogramV3EditRequest:\n      type: object\n      required:\n        - prompt\n        - rendering_speed\n      properties:\n        image:\n          type: string\n          format: binary\n          description: The image being edited (max size 10MB); only JPEG, WebP and PNG formats are supported at this time.\n        mask:\n          type: string\n          format: binary\n          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.\n        prompt:\n          type: string\n          description: The prompt used to describe the edited result.\n        magic_prompt:\n          type: string\n          description: Determine if MagicPrompt should be used in generating the request or not.\n        num_images:\n          type: integer\n          description: The number of images to generate.\n        seed:\n          type: integer\n          description: Random seed. Set for reproducible generation.\n        rendering_speed:\n          $ref: \"#/components/schemas/RenderingSpeed\"\n        style_type:\n          $ref: \"#/components/schemas/IdeogramStyleType\"\n        color_palette:\n          type: object\n          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.\n          $ref: \"#/components/schemas/IdeogramColorPalette\"\n        style_codes:\n          type: array\n          items:\n            type: string\n            pattern: \"^[0-9A-Fa-f]{8}$\"\n          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.\n        style_reference_images:\n          type: array\n          items:\n            type: string\n            format: binary\n          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.\n        character_reference_images:\n          type: array\n          items:\n            type: string\n            format: binary\n          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.\n        character_reference_images_mask:\n          type: array\n          items:\n            type: string\n            format: binary\n          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.\n    IdeogramColorPalette:\n      type: object\n      description: A color palette specification that can either use a preset name or explicit color definitions with weights\n      oneOf:\n        - properties:\n            name:\n              type: string\n              description: Name of the preset color palette\n          required:\n            - name\n        - properties:\n            members:\n              type: array\n              items:\n                type: object\n                properties:\n                  color:\n                    type: string\n                    pattern: \"^#[0-9A-Fa-f]{6}$\"\n                    description: Hexadecimal color code\n                  weight:\n                    type: number\n                    minimum: 0\n                    maximum: 1\n                    description: Optional weight for the color (0-1)\n              description: Array of color definitions with optional weights\n          required:\n            - members\n    IdeogramGenerateRequest:\n      type: object\n      description: Parameters for the Ideogram generation proxy request. Based on Ideogram's API.\n      properties:\n        image_request:\n          type: object\n          description: The image generation request parameters.\n          properties:\n            prompt:\n              type: string\n              description: Required. The prompt to use to generate the image.\n            aspect_ratio:\n              type: string\n              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.\"\n            model:\n              type: string\n              description: \"The model used (e.g., 'V_2', 'V_2A_TURBO')\"\n            magic_prompt_option:\n              type: string\n              description: \"Optional. MagicPrompt usage ('AUTO', 'ON', 'OFF').\"\n            seed:\n              type: integer\n              format: int64\n              description: \"Optional. A number between 0 and 2147483647.\"\n              minimum: 0\n              maximum: 2147483647\n            style_type:\n              type: string\n              description: \"Optional. Style type ('AUTO', 'GENERAL', 'REALISTIC', 'DESIGN', 'RENDER_3D', 'ANIME'). Only for models V_2 and above.\"\n            negative_prompt:\n              type: string\n              description: \"Optional. Description of what to exclude. Only for V_1, V_1_TURBO, V_2, V_2_TURBO.\"\n            num_images:\n              type: integer\n              description: \"Optional. Number of images to generate (1-8). Defaults to 1.\"\n              minimum: 1\n              maximum: 8\n              default: 1\n            resolution:\n              type: string\n              description: \"Optional. Resolution (e.g., 'RESOLUTION_1024_1024'). Only for model V_2. Cannot be used with aspect_ratio.\"\n            color_palette:\n              type: object\n              description: \"Optional. Color palette object. Only for V_2, V_2_TURBO.\"\n              additionalProperties: true\n          required:\n            - prompt\n            - model\n      required:\n        - image_request\n    IdeogramGenerateResponse:\n      type: object\n      description: Response from the Ideogram image generation API.\n      properties:\n        created:\n          type: string\n          format: date-time\n          description: Timestamp when the generation was created.\n        data:\n          type: array\n          description: Array of generated image information.\n          items:\n            type: object\n            properties:\n              prompt:\n                type: string\n                description: The prompt used to generate this image.\n              resolution:\n                type: string\n                description: The resolution of the generated image (e.g., '1024x1024').\n              is_image_safe:\n                type: boolean\n                description: Indicates whether the image is considered safe.\n              seed:\n                type: integer\n                description: The seed value used for this generation.\n              url:\n                type: string\n                description: URL to the generated image.\n              style_type:\n                type: string\n                description: The style type used for generation (e.g., 'REALISTIC', 'ANIME').\n    IdeogramV3RemixRequest:\n      type: object\n      required:\n        - prompt\n      properties:\n        image:\n          type: string\n          format: binary\n        prompt:\n          type: string\n        image_weight:\n          type: integer\n          minimum: 1\n          maximum: 100\n          default: 50\n        seed:\n          type: integer\n          minimum: 0\n          maximum: 2147483647\n        resolution:\n          type: string\n        aspect_ratio:\n          type: string\n        rendering_speed:\n          $ref: \"#/components/schemas/RenderingSpeed\"\n        magic_prompt:\n          type: string\n          enum: [AUTO, ON, OFF]\n        negative_prompt:\n          type: string\n        num_images:\n          type: integer\n          minimum: 1\n          maximum: 8\n        color_palette:\n          type: object\n        style_codes:\n          type: array\n          items:\n            type: string\n        style_type:\n          $ref: \"#/components/schemas/IdeogramStyleType\"\n        style_reference_images:\n          type: array\n          items:\n            type: string\n            format: binary\n        character_reference_images:\n          type: array\n          items:\n            type: string\n            format: binary\n          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.\n        character_reference_images_mask:\n          type: array\n          items:\n            type: string\n            format: binary\n          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.\n    IdeogramV3IdeogramResponse:\n      type: object\n      properties:\n        created:\n          type: string\n          format: date-time\n        data:\n          type: array\n          items:\n            type: object\n            properties:\n              prompt:\n                type: string\n              resolution:\n                type: string\n              is_image_safe:\n                type: boolean\n              seed:\n                type: integer\n              url:\n                type: string\n              style_type:\n                type: string\n    IdeogramV3ReframeRequest:\n      type: object\n      required:\n        - resolution\n      properties:\n        image:\n          type: string\n          format: binary\n        resolution:\n          type: string\n        num_images:\n          type: integer\n          minimum: 1\n          maximum: 8\n        seed:\n          type: integer\n          minimum: 0\n          maximum: 2147483647\n        rendering_speed:\n          $ref: \"#/components/schemas/RenderingSpeed\"\n        color_palette:\n          type: object\n        style_codes:\n          type: array\n          items:\n            type: string\n        style_reference_images:\n          type: array\n          items:\n            type: string\n            format: binary\n    IdeogramV3ReplaceBackgroundRequest:\n      type: object\n      required:\n        - prompt\n      properties:\n        image:\n          type: string\n          format: binary\n        prompt:\n          type: string\n        magic_prompt:\n          type: string\n          enum: [AUTO, ON, OFF]\n        num_images:\n          type: integer\n          minimum: 1\n          maximum: 8\n        seed:\n          type: integer\n          minimum: 0\n          maximum: 2147483647\n        rendering_speed:\n          $ref: \"#/components/schemas/RenderingSpeed\"\n        color_palette:\n          type: object\n        style_codes:\n          type: array\n          items:\n            type: string\n        style_reference_images:\n          type: array\n          items:\n            type: string\n            format: binary\n    KlingTaskStatus:\n      type: string\n      enum: [submitted, processing, succeed, failed]\n      description: Task Status\n    # Kling Video Generation Request Properties\n    KlingTextToVideoModelName:\n      type: string\n      enum:\n        [kling-v1, kling-v1-5, kling-v1-6, kling-v2-master, kling-v2-1-master, kling-v2-5-turbo, kling-v2-6, kling-v3]\n      default: kling-v1\n      description: Model Name\n    KlingVideoGenModelName:\n      type: string\n      enum:\n        [\n          kling-v1,\n          kling-v1-5,\n          kling-v1-6,\n          kling-v2-master,\n          kling-v2-1,\n          kling-v2-1-master,\n          kling-v2-5-turbo,\n          kling-v2-6,\n          kling-v3\n        ]\n      default: kling-v2-master\n      description: Model Name\n    KlingVideoGenMode:\n      type: string\n      enum: [std, pro]\n      default: std\n      description: \"Video generation mode. std: Standard Mode, which is cost-effective. pro: Professional Mode, generates videos with longer duration but higher quality output.\"\n    KlingVideoGenAspectRatio:\n      type: string\n      enum: [\"16:9\", \"9:16\", \"1:1\"]\n      default: \"16:9\"\n      description: Video aspect ratio\n    KlingVideoGenDuration:\n      type: string\n      enum: [\"3\", \"4\", \"5\", \"6\", \"7\", \"8\", \"9\", \"10\", \"11\", \"12\", \"13\", \"14\", \"15\"]\n      default: \"5\"\n      description: Video length in seconds\n    KlingVideoGenCfgScale:\n      type: number\n      format: float\n      default: 0.5\n      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.\n      minimum: 0\n      maximum: 1\n    KlingCameraControl:\n      type: object\n      properties:\n        type:\n          $ref: \"#/components/schemas/KlingCameraControlType\"\n        config:\n          $ref: \"#/components/schemas/KlingCameraConfig\"\n    KlingCameraControlType:\n      type: string\n      enum:\n        [simple, down_back, forward_up, right_turn_forward, left_turn_forward]\n      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.\"\n    KlingCameraConfig:\n      type: object\n      properties:\n        horizontal:\n          type: number\n          minimum: -10\n          maximum: 10\n          description: Controls camera's movement along horizontal axis (x-axis). Negative indicates left, positive indicates right.\n        vertical:\n          type: number\n          minimum: -10\n          maximum: 10\n          description: Controls camera's movement along vertical axis (y-axis). Negative indicates downward, positive indicates upward.\n        pan:\n          type: number\n          minimum: -10\n          maximum: 10\n          description: Controls camera's rotation in vertical plane (x-axis). Negative indicates downward rotation, positive indicates upward rotation.\n        tilt:\n          type: number\n          minimum: -10\n          maximum: 10\n          description: Controls camera's rotation in horizontal plane (y-axis). Negative indicates left rotation, positive indicates right rotation.\n        roll:\n          type: number\n          minimum: -10\n          maximum: 10\n          description: Controls camera's rolling amount (z-axis). Negative indicates counterclockwise, positive indicates clockwise.\n        zoom:\n          type: number\n          minimum: -10\n          maximum: 10\n          description: Controls change in camera's focal length. Negative indicates narrower field of view, positive indicates wider field of view.\n    # Kling Video Generation Response Properties\n    KlingVideoResult:\n      type: object\n      properties:\n        id:\n          type: string\n          description: Generated video ID\n        url:\n          type: string\n          format: uri\n          description: URL for generated video\n        watermark_url:\n          type: string\n          format: uri\n          description: URL for generated video with watermark, hotlink protection format\n        duration:\n          type: string\n          description: Total video duration in seconds\n    # Kling Lip Sync Request Properties\n    KlingAudioUploadType:\n      type: string\n      enum: [file, url]\n      description: \"Method of Transmitting Audio Files for Lip-Sync. Required when mode is audio2video.\"\n    KlingLipSyncMode:\n      type: string\n      enum: [text2video, audio2video]\n      description: \"Video Generation Mode. text2video: Text-to-video generation mode; audio2video: Audio-to-video generation mode\"\n    KlingLipSyncVoiceLanguage:\n      type: string\n      enum: [zh, en]\n      default: en\n      description: \"The voice language corresponds to the Voice ID.\"\n    # Kling Video Effects Request Properties\n    KlingDualCharacterEffectsScene:\n      type: string\n      enum: [hug, kiss, heart_gesture]\n      description: Scene Name. Dual-character Effects (hug, kiss, heart_gesture).\n    KlingSingleImageEffectsScene:\n      type: string\n      enum: [bloombloom, dizzydizzy, fuzzyfuzzy, squish, expansion]\n      description: Scene Name. Single Image Effects (bloombloom, dizzydizzy, fuzzyfuzzy, squish, expansion).\n    KlingCharacterEffectModelName:\n      type: string\n      enum: [kling-v1, kling-v1-5, kling-v1-6]\n      default: kling-v1\n      description: Model Name. Can be kling-v1, kling-v1-5, or kling-v1-6.\n    KlingSingleImageEffectModelName:\n      type: string\n      enum: [kling-v1-6]\n      description: Model Name. Only kling-v1-6 is supported for single image effects.\n    KlingSingleImageEffectDuration:\n      type: string\n      enum: [\"5\"]\n      description: Video Length in seconds. Only 5-second videos are supported.\n    KlingDualCharacterImages:\n      type: array\n      minItems: 2\n      maxItems: 2\n      items:\n        type: string\n        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.\n    # Kling Image Generation Request Properties\n    KlingImageGenAspectRatio:\n      type: string\n      enum: [\"16:9\", \"9:16\", \"1:1\", \"4:3\", \"3:4\", \"3:2\", \"2:3\", \"21:9\"]\n      default: \"16:9\"\n      description: Aspect ratio of the generated images\n    KlingImageGenImageReferenceType:\n      type: string\n      enum: [subject, face]\n      description: Image reference type\n    KlingImageGenModelName:\n      type: string\n      enum: [kling-v1, kling-v1-5, kling-v2, kling-v3]\n      default: kling-v1\n      description: Model Name\n    # Kling Image Generation Response Properties\n    KlingImageResult:\n      type: object\n      properties:\n        index:\n          type: integer\n          description: Image Number (0-9)\n        url:\n          type: string\n          format: uri\n          description: URL for generated image\n    # Kling Virtual Try On Request Properties\n    KlingVirtualTryOnModelName:\n      type: string\n      enum: [kolors-virtual-try-on-v1, kolors-virtual-try-on-v1-5]\n      default: kolors-virtual-try-on-v1\n      description: Model Name\n    # Kling Requests and Responses\n    KlingText2VideoRequest:\n      type: object\n      properties:\n        model_name:\n          $ref: \"#/components/schemas/KlingTextToVideoModelName\"\n        multi_shot:\n          type: boolean\n          default: false\n          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.\n        shot_type:\n          type: string\n          enum: [customize]\n          description: Storyboard method. Required when the multi_shot parameter is set to true.\n        prompt:\n          type: string\n          maxLength: 2500\n          description: Positive text prompt. Use <<<voice_1>>> 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.\n        multi_prompt:\n          type: array\n          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.\n          items:\n            type: object\n            properties:\n              index:\n                type: integer\n                description: Shot sequence number\n              prompt:\n                type: string\n                maxLength: 512\n                description: Prompt word for this storyboard. Maximum length 512 characters.\n              duration:\n                type: string\n                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.\n        negative_prompt:\n          type: string\n          maxLength: 2500\n          description: Negative text prompt. It is recommended to supplement negative prompt information through negative sentences directly within positive prompts.\n        cfg_scale:\n          $ref: \"#/components/schemas/KlingVideoGenCfgScale\"\n        mode:\n          $ref: \"#/components/schemas/KlingVideoGenMode\"\n        camera_control:\n          $ref: \"#/components/schemas/KlingCameraControl\"\n        aspect_ratio:\n          $ref: \"#/components/schemas/KlingVideoGenAspectRatio\"\n        duration:\n          $ref: \"#/components/schemas/KlingVideoGenDuration\"\n        sound:\n          type: string\n          enum: [on, off]\n          default: off\n          description: Whether to generate sound simultaneously when generating videos. Only V2.6 and subsequent versions of the model support this parameter.\n        watermark_info:\n          type: object\n          description: Whether to generate watermarked results simultaneously. Custom watermark is not supported at this time.\n          properties:\n            enabled:\n              type: boolean\n              description: true means generate watermark, false means do not generate.\n        callback_url:\n          type: string\n          format: uri\n          description: The callback notification address\n        external_task_id:\n          type: string\n          description: Customized Task ID\n    KlingText2VideoResponse:\n      type: object\n      properties:\n        code:\n          type: integer\n          description: Error code\n        message:\n          type: string\n          description: Error message\n        request_id:\n          type: string\n          description: Request ID\n        data:\n          type: object\n          properties:\n            task_id:\n              type: string\n              description: Task ID\n            task_status:\n              $ref: \"#/components/schemas/KlingTaskStatus\"\n            task_status_msg:\n              type: string\n              description: Task status information, displaying the failure reason when the task fails\n            task_info:\n              type: object\n              properties:\n                external_task_id:\n                  type: string\n            watermark_info:\n              type: object\n              properties:\n                enabled:\n                  type: boolean\n            final_unit_deduction:\n              type: string\n              description: The deduction units of task\n            created_at:\n              type: integer\n              description: Task creation time, Unix timestamp in milliseconds\n            updated_at:\n              type: integer\n              description: Task update time, Unix timestamp in milliseconds\n            task_result:\n              type: object\n              properties:\n                videos:\n                  type: array\n                  items:\n                    $ref: \"#/components/schemas/KlingVideoResult\"\n    KlingImage2VideoRequest:\n      type: object\n      properties:\n        model_name:\n          $ref: \"#/components/schemas/KlingVideoGenModelName\"\n        image:\n          type: string\n          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.\n        image_tail:\n          type: string\n          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.\n        multi_shot:\n          type: boolean\n          default: false\n          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.\n        shot_type:\n          type: string\n          enum: [customize]\n          description: Storyboard method. Required when the multi_shot parameter is set to true.\n        prompt:\n          type: string\n          maxLength: 2500\n          description: Positive text prompt. Use <<<voice_1>>> 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.\n        multi_prompt:\n          type: array\n          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.\n          items:\n            type: object\n            properties:\n              index:\n                type: integer\n                description: Shot sequence number\n              prompt:\n                type: string\n                maxLength: 512\n                description: Prompt word for this storyboard. Maximum length 512 characters.\n              duration:\n                type: string\n                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.\n        negative_prompt:\n          type: string\n          maxLength: 2500\n          description: Negative text prompt. It is recommended to supplement negative prompt information through negative sentences directly within positive prompts.\n        element_list:\n          type: array\n          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.\n          items:\n            type: object\n            properties:\n              element_id:\n                type: integer\n                format: int64\n                description: Element ID\n        cfg_scale:\n          $ref: \"#/components/schemas/KlingVideoGenCfgScale\"\n        mode:\n          $ref: \"#/components/schemas/KlingVideoGenMode\"\n        static_mask:\n          type: string\n          description: Static Brush Application Area (Mask image created by users using the motion brush). The aspect ratio must match the input image.\n        dynamic_masks:\n          type: array\n          items:\n            type: object\n            properties:\n              mask:\n                type: string\n                format: uri\n                description: Dynamic Brush Application Area (Mask image created by users using the motion brush). The aspect ratio must match the input image.\n              trajectories:\n                type: array\n                items:\n                  type: object\n                  properties:\n                    x:\n                      type: integer\n                      description: The horizontal coordinate of trajectory point. Based on bottom-left corner of image as origin (0,0).\n                    y:\n                      type: integer\n                      description: The vertical coordinate of trajectory point. Based on bottom-left corner of image as origin (0,0).\n          description: Dynamic Brush Configuration List (up to 6 groups). For 5-second videos, trajectory length must not exceed 77 coordinates.\n        camera_control:\n          $ref: \"#/components/schemas/KlingCameraControl\"\n        aspect_ratio:\n          $ref: \"#/components/schemas/KlingVideoGenAspectRatio\"\n        duration:\n          $ref: \"#/components/schemas/KlingVideoGenDuration\"\n        sound:\n          type: string\n          enum: [on, off]\n          default: off\n          description: Whether to generate sound simultaneously when generating videos. Only V2.6 and subsequent versions of the model support this parameter.\n        watermark_info:\n          type: object\n          description: Whether to generate watermarked results simultaneously. Custom watermark is not supported at this time.\n          properties:\n            enabled:\n              type: boolean\n              description: true means generate watermark, false means do not generate.\n        callback_url:\n          type: string\n          format: uri\n          description: The callback notification address. Server will notify when the task status changes.\n        external_task_id:\n          type: string\n          description: Customized Task ID. Must be unique within a single user account.\n    KlingImage2VideoResponse:\n      type: object\n      properties:\n        code:\n          type: integer\n          description: Error code\n        message:\n          type: string\n          description: Error message\n        request_id:\n          type: string\n          description: Request ID\n        data:\n          type: object\n          properties:\n            task_id:\n              type: string\n              description: Task ID\n            task_status:\n              $ref: \"#/components/schemas/KlingTaskStatus\"\n            task_status_msg:\n              type: string\n              description: Task status information, displaying the failure reason when the task fails\n            task_info:\n              type: object\n              properties:\n                external_task_id:\n                  type: string\n            watermark_info:\n              type: object\n              properties:\n                enabled:\n                  type: boolean\n            final_unit_deduction:\n              type: string\n              description: The deduction units of task\n            created_at:\n              type: integer\n              description: Task creation time, Unix timestamp in milliseconds\n            updated_at:\n              type: integer\n              description: Task update time, Unix timestamp in milliseconds\n            task_result:\n              type: object\n              properties:\n                videos:\n                  type: array\n                  items:\n                    $ref: \"#/components/schemas/KlingVideoResult\"\n    KlingVideoExtendRequest:\n      type: object\n      properties:\n        video_id:\n          type: string\n          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.\n        prompt:\n          type: string\n          maxLength: 2500\n          description: Positive text prompt for guiding the video extension\n        negative_prompt:\n          type: string\n          maxLength: 2500\n          description: Negative text prompt for elements to avoid in the extended video\n        cfg_scale:\n          $ref: \"#/components/schemas/KlingVideoGenCfgScale\"\n        callback_url:\n          type: string\n          format: uri\n          description: The callback notification address. Server will notify when the task status changes.\n    KlingVideoExtendResponse:\n      type: object\n      properties:\n        code:\n          type: integer\n          description: Error code\n        message:\n          type: string\n          description: Error message\n        request_id:\n          type: string\n          description: Request ID\n        data:\n          type: object\n          properties:\n            task_id:\n              type: string\n              description: Task ID\n            task_status:\n              $ref: \"#/components/schemas/KlingTaskStatus\"\n            task_info:\n              type: object\n              properties:\n                external_task_id:\n                  type: string\n            created_at:\n              type: integer\n              description: Task creation time\n            updated_at:\n              type: integer\n              description: Task update time\n            task_result:\n              type: object\n              properties:\n                videos:\n                  type: array\n                  items:\n                    $ref: \"#/components/schemas/KlingVideoResult\"\n    KlingOmniVideoRequest:\n      type: object\n      properties:\n        model_name:\n          type: string\n          enum: [kling-video-o1, kling-v3-omni]\n          default: kling-video-o1\n          description: Model Name\n        multi_shot:\n          type: boolean\n          default: false\n          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.\n        shot_type:\n          type: string\n          enum: [customize]\n          description: Storyboard method. Required when the multi_shot parameter is set to true.\n        prompt:\n          type: string\n          maxLength: 2500\n          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 <<element_1>>, <<<image_1>>>, <<<video_1>>>.\n        multi_prompt:\n          type: array\n          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.\n          items:\n            type: object\n            properties:\n              index:\n                type: integer\n                description: Shot sequence number\n              prompt:\n                type: string\n                maxLength: 512\n                description: Prompt word for this storyboard. Maximum length 512 characters.\n              duration:\n                type: string\n                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.\n        image_list:\n          type: array\n          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.\n          items:\n            type: object\n            properties:\n              image_url:\n                type: string\n                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.\n              type:\n                type: string\n                enum: [first_frame, end_frame]\n                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.\n        element_list:\n          type: array\n          description: Reference Element List based on element ID configuration.\n          items:\n            type: object\n            properties:\n              element_id:\n                type: integer\n                format: int64\n                description: Element ID\n        video_list:\n          type: array\n          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.\n          items:\n            type: object\n            properties:\n              video_url:\n                type: string\n                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.\n              refer_type:\n                type: string\n                enum: [feature, base]\n                description: Reference video type. feature is the feature reference video, base is the video to be edited.\n              keep_original_sound:\n                type: string\n                enum: [yes, no]\n                description: Whether to keep the video original sound. yes indicates retention, no indicates non retention.\n        sound:\n          type: string\n          enum: [on, off]\n          default: \"off\"\n          description: Whether sound is generated simultaneously when generating videos.\n        mode:\n          type: string\n          enum: [pro, std]\n          default: std\n          description: \"Video generation mode. std: Standard Mode, generating 720P videos, cost-effective. pro: Professional Mode, generating 1080P videos, higher quality video output.\"\n        aspect_ratio:\n          type: string\n          enum: [16:9, 9:16, 1:1]\n          description: The aspect ratio of the generated video frame (width:height). Required when first-frame reference or video editing features are not used.\n        duration:\n          type: string\n          enum: [\"3\", \"4\", \"5\", \"6\", \"7\", \"8\", \"9\", \"10\", \"11\", \"12\", \"13\", \"14\", \"15\"]\n          default: \"5\"\n          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.\"\n        watermark_info:\n          type: object\n          description: Whether to generate watermarked results simultaneously. Custom watermark is not supported at this time.\n          properties:\n            enabled:\n              type: boolean\n              description: true means generate watermark, false means do not generate.\n        callback_url:\n          type: string\n          format: uri\n          description: The callback notification address for the result of this task. If configured, the server will actively notify when the task status changes.\n        external_task_id:\n          type: string\n          description: Customized Task ID. Must be unique within a single user account.\n    KlingOmniVideoResponse:\n      type: object\n      properties:\n        code:\n          type: integer\n          description: Error code\n        message:\n          type: string\n          description: Error message\n        request_id:\n          type: string\n          description: Request ID\n        data:\n          type: object\n          properties:\n            task_id:\n              type: string\n              description: Task ID\n            task_status:\n              $ref: \"#/components/schemas/KlingTaskStatus\"\n            task_status_msg:\n              type: string\n              description: Task status information, displaying the failure reason when the task fails\n            task_info:\n              type: object\n              properties:\n                external_task_id:\n                  type: string\n            watermark_info:\n              type: object\n              properties:\n                enabled:\n                  type: boolean\n            final_unit_deduction:\n              type: string\n              description: The deduction units of task\n            created_at:\n              type: integer\n              description: Task creation time, Unix timestamp in milliseconds\n            updated_at:\n              type: integer\n              description: Task update time, Unix timestamp in milliseconds\n            task_result:\n              type: object\n              properties:\n                videos:\n                  type: array\n                  items:\n                    $ref: \"#/components/schemas/KlingVideoResult\"\n    KlingOmniImageRequest:\n      type: object\n      required: [prompt]\n      properties:\n        model_name:\n          type: string\n          enum: [kling-image-o1, kling-v3-omni]\n          default: kling-image-o1\n          description: Model Name\n        prompt:\n          type: string\n          maxLength: 2500\n          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_1>>>.\n        image_list:\n          type: array\n          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.\n          items:\n            type: object\n            properties:\n              image:\n                type: string\n                description: Image Base64 encoding or image URL (ensure accessibility)\n        element_list:\n          type: array\n          description: Reference Element List based on element ID configuration. The sum of reference elements and reference images shall not exceed 10.\n          items:\n            type: object\n            properties:\n              element_id:\n                type: integer\n                format: int64\n                description: Element ID\n        resolution:\n          type: string\n          enum: [\"1k\", \"2k\", \"4k\"]\n          default: \"1k\"\n          description: Image generation resolution. 1k is 1K standard, 2k is 2K high-res, 4k is 4K high-res.\n        result_type:\n          type: string\n          enum: [single, series]\n          default: single\n          description: Control whether to generate a single image or a series of images.\n        n:\n          type: integer\n          minimum: 1\n          maximum: 9\n          default: 1\n          description: Number of generated images. Value range [1,9].\n        series_amount:\n          type: integer\n          minimum: 2\n          maximum: 9\n          default: 4\n          description: Number of images in a series. Value range [2,9].\n        aspect_ratio:\n          type: string\n          enum: [\"16:9\", \"9:16\", \"1:1\", \"4:3\", \"3:4\", \"3:2\", \"2:3\", \"21:9\", \"auto\"]\n          default: auto\n          description: Aspect ratio of the generated images (width:height). auto is to intelligently generate images based on incoming content.\n        callback_url:\n          type: string\n          format: uri\n          description: The callback notification address for the result of this task. If configured, the server will actively notify when the task status changes.\n        external_task_id:\n          type: string\n          description: Customized Task ID. Must be unique within a single user account.\n    KlingOmniImageResponse:\n      type: object\n      properties:\n        code:\n          type: integer\n          description: Error code\n        message:\n          type: string\n          description: Error message\n        request_id:\n          type: string\n          description: Request ID\n        data:\n          type: object\n          properties:\n            task_id:\n              type: string\n              description: Task ID\n            task_status:\n              $ref: \"#/components/schemas/KlingTaskStatus\"\n            task_status_msg:\n              type: string\n              description: Task status information, displaying the failure reason when the task fails (such as triggering the content risk control of the platform, etc.)\n            task_info:\n              type: object\n              properties:\n                external_task_id:\n                  type: string\n                  description: Customer-defined task ID\n            final_unit_deduction:\n              type: string\n              description: The deduction units of task\n            created_at:\n              type: integer\n              description: Task creation time, Unix timestamp in milliseconds\n            updated_at:\n              type: integer\n              description: Task update time, Unix timestamp in milliseconds\n            task_result:\n              type: object\n              properties:\n                result_type:\n                  type: string\n                  enum: [single, series]\n                  description: Whether the result is a single image or a series of images\n                images:\n                  type: array\n                  items:\n                    $ref: \"#/components/schemas/KlingImageResult\"\n                series_images:\n                  type: array\n                  description: Series images result list\n                  items:\n                    type: object\n                    properties:\n                      index:\n                        type: integer\n                        description: Series-image sequence number\n                      url:\n                        type: string\n                        format: uri\n                        description: URL for generated image\n    KlingLipSyncInputObject:\n      type: object\n      required: [mode]\n      properties:\n        video_id:\n          type: string\n          description: \"The ID of the video generated by Kling AI. Only supports 5-second and 10-second videos generated within the last 30 days.\"\n        video_url:\n          type: string\n          description: \"Get link for uploaded video. Video files support .mp4/.mov, file size does not exceed 100MB, video length between 2-10s.\"\n        mode:\n          $ref: \"#/components/schemas/KlingLipSyncMode\"\n        text:\n          type: string\n          description: \"Text Content for Lip-Sync Video Generation. Required when mode is text2video. Maximum length is 120 characters.\"\n        voice_id:\n          type: string\n          description: \"Voice ID. Required when mode is text2video. The system offers a variety of voice options to choose from.\"\n        voice_language:\n          $ref: \"#/components/schemas/KlingLipSyncVoiceLanguage\"\n        voice_speed:\n          type: number\n          minimum: 0.8\n          maximum: 2.0\n          default: 1.0\n          description: \"Speech Rate. Valid range: 0.8~2.0, accurate to one decimal place.\"\n        audio_type:\n          $ref: \"#/components/schemas/KlingAudioUploadType\"\n        audio_file:\n          type: string\n          description: \"Local Path of Audio File. Supported formats: .mp3/.wav/.m4a/.aac, maximum file size of 5MB. Base64 code.\"\n        audio_url:\n          type: string\n          description: \"Audio File Download URL. Supported formats: .mp3/.wav/.m4a/.aac, maximum file size of 5MB.\"\n    KlingLipSyncRequest:\n      type: object\n      required: [input]\n      properties:\n        input:\n          $ref: \"#/components/schemas/KlingLipSyncInputObject\"\n        callback_url:\n          type: string\n          format: uri\n          description: \"The callback notification address. Server will notify when the task status changes.\"\n    KlingLipSyncResponse:\n      type: object\n      properties:\n        code:\n          type: integer\n          description: Error code\n        message:\n          type: string\n          description: Error message\n        request_id:\n          type: string\n          description: Request ID\n        data:\n          type: object\n          properties:\n            task_id:\n              type: string\n              description: Task ID\n            task_status:\n              $ref: \"#/components/schemas/KlingTaskStatus\"\n            task_info:\n              type: object\n              properties:\n                external_task_id:\n                  type: string\n            created_at:\n              type: integer\n              description: Task creation time\n            updated_at:\n              type: integer\n              description: Task update time\n            task_result:\n              type: object\n              properties:\n                videos:\n                  type: array\n                  items:\n                    $ref: \"#/components/schemas/KlingVideoResult\"\n    KlingAvatarRequest:\n      type: object\n      required: [image]\n      properties:\n        image:\n          type: string\n          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.\"\n        audio_id:\n          type: string\n          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).\"\n        sound_file:\n          type: string\n          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).\"\n        prompt:\n          type: string\n          maxLength: 2500\n          description: \"Positive text prompt. Can define avatar actions, emotions, and camera movements.\"\n        mode:\n          $ref: \"#/components/schemas/KlingAvatarMode\"\n        watermark_info:\n          type: object\n          properties:\n            enabled:\n              type: boolean\n              description: \"Whether to generate watermarked results simultaneously.\"\n        callback_url:\n          type: string\n          format: uri\n          description: \"The callback notification address for the result of this task.\"\n        external_task_id:\n          type: string\n          description: \"Customized Task ID. Must be unique within a single user account.\"\n    KlingAvatarMode:\n      type: string\n      enum: [std, pro]\n      default: std\n      description: \"Video generation mode. std: Standard Mode (cost-effective), pro: Professional Mode (longer duration, higher quality).\"\n    KlingAvatarResponse:\n      type: object\n      properties:\n        code:\n          type: integer\n          description: Error code\n        message:\n          type: string\n          description: Error message\n        request_id:\n          type: string\n          description: Request ID\n        data:\n          type: object\n          properties:\n            task_id:\n              type: string\n              description: Task ID\n            task_status:\n              $ref: \"#/components/schemas/KlingTaskStatus\"\n            task_status_msg:\n              type: string\n              description: Task status information\n            task_info:\n              type: object\n              properties:\n                external_task_id:\n                  type: string\n            watermark_info:\n              type: object\n              properties:\n                enabled:\n                  type: boolean\n            final_unit_deduction:\n              type: string\n              description: The deduction units of task\n            created_at:\n              type: integer\n              description: Task creation time\n            updated_at:\n              type: integer\n              description: Task update time\n            task_result:\n              type: object\n              properties:\n                videos:\n                  type: array\n                  items:\n                    $ref: \"#/components/schemas/KlingVideoResult\"\n    KlingVideoEffectsRequest:\n      type: object\n      required: [effect_scene, input]\n      properties:\n        effect_scene:\n          oneOf:\n            - $ref: \"#/components/schemas/KlingDualCharacterEffectsScene\"\n            - $ref: \"#/components/schemas/KlingSingleImageEffectsScene\"\n        input:\n          $ref: \"#/components/schemas/KlingVideoEffectsInput\"\n        callback_url:\n          type: string\n          format: uri\n          description: The callback notification address for the result of this task.\n        external_task_id:\n          type: string\n          description: Customized Task ID. Must be unique within a single user account.\n    KlingVideoEffectsInput:\n      oneOf:\n        - $ref: \"#/components/schemas/KlingSingleImageEffectInput\"\n        - $ref: \"#/components/schemas/KlingDualCharacterEffectInput\"\n    KlingSingleImageEffectInput:\n      type: object\n      required: [model_name, image, duration]\n      properties:\n        model_name:\n          $ref: \"#/components/schemas/KlingSingleImageEffectModelName\"\n        image:\n          type: string\n          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.\n        duration:\n          $ref: \"#/components/schemas/KlingSingleImageEffectDuration\"\n    KlingDualCharacterEffectInput:\n      type: object\n      required: [images, duration]\n      properties:\n        model_name:\n          $ref: \"#/components/schemas/KlingCharacterEffectModelName\"\n        mode:\n          $ref: \"#/components/schemas/KlingVideoGenMode\"\n        images:\n          $ref: \"#/components/schemas/KlingDualCharacterImages\"\n        duration:\n          $ref: \"#/components/schemas/KlingVideoGenDuration\"\n    KlingVideoEffectsResponse:\n      type: object\n      properties:\n        code:\n          type: integer\n          description: Error code\n        message:\n          type: string\n          description: Error message\n        request_id:\n          type: string\n          description: Request ID\n        data:\n          type: object\n          properties:\n            task_id:\n              type: string\n              description: Task ID\n            task_status:\n              $ref: \"#/components/schemas/KlingTaskStatus\"\n            task_info:\n              type: object\n              properties:\n                external_task_id:\n                  type: string\n            created_at:\n              type: integer\n              description: Task creation time\n            updated_at:\n              type: integer\n              description: Task update time\n            task_result:\n              type: object\n              properties:\n                videos:\n                  type: array\n                  items:\n                    $ref: \"#/components/schemas/KlingVideoResult\"\n    KlingMotionControlRequest:\n      type: object\n      required: [image_url, video_url, character_orientation, mode]\n      properties:\n        model_name:\n          type: string\n          enum: [kling-v2-6, kling-v3]\n          default: \"kling-v2-6\"\n          description: Model name for motion control. Enum values - kling-v2-6, kling-v3.\n        prompt:\n          type: string\n          maxLength: 2500\n          description: Text prompt words, which can include positive and negative descriptions. Cannot exceed 2500 characters.\n        image_url:\n          type: string\n          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.\n        video_url:\n          type: string\n          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.\n        element_list:\n          type: array\n          description: Reference Element List based on element ID configuration. Currently only one element can be introduced.\n          items:\n            type: object\n            properties:\n              element_id:\n                type: integer\n                format: int64\n                description: Element ID\n        keep_original_sound:\n          type: string\n          enum: [yes, no]\n          default: \"yes\"\n          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).\n        character_orientation:\n          type: string\n          enum: [image, video]\n          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).\n        mode:\n          type: string\n          enum: [std, pro]\n          description: Video generation mode. std - Standard Mode (cost-effective). pro - Professional Mode (longer duration but higher quality video output).\n        watermark_info:\n          type: object\n          description: Whether to generate watermarked results simultaneously. Custom watermark is not supported at this time.\n          properties:\n            enabled:\n              type: boolean\n              description: true means generate watermark, false means do not generate.\n        callback_url:\n          type: string\n          format: uri\n          description: The callback notification address for the result of this task. If configured, the server will actively notify when the task status changes.\n        external_task_id:\n          type: string\n          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.\n    KlingMotionControlResponse:\n      type: object\n      properties:\n        code:\n          type: integer\n          description: Error code\n        message:\n          type: string\n          description: Error message\n        request_id:\n          type: string\n          description: Request ID\n        data:\n          type: object\n          properties:\n            task_id:\n              type: string\n              description: Task ID\n            task_status:\n              $ref: \"#/components/schemas/KlingTaskStatus\"\n            task_status_msg:\n              type: string\n              description: Task status information, displaying the failure reason when the task fails\n            task_info:\n              type: object\n              properties:\n                external_task_id:\n                  type: string\n                  description: Customer-defined task ID\n            watermark_info:\n              type: object\n              properties:\n                enabled:\n                  type: boolean\n            final_unit_deduction:\n              type: string\n              description: The deduction units of task\n            created_at:\n              type: integer\n              description: Task creation time, Unix timestamp, unit ms\n            updated_at:\n              type: integer\n              description: Task update time, Unix timestamp, unit ms\n            task_result:\n              type: object\n              properties:\n                videos:\n                  type: array\n                  items:\n                    $ref: \"#/components/schemas/KlingMotionControlVideoResult\"\n    KlingMotionControlVideoResult:\n      type: object\n      properties:\n        id:\n          type: string\n          description: Generated video ID; globally unique\n        url:\n          type: string\n          description: URL for generating videos\n        watermark_url:\n          type: string\n          description: URL for generating videos with watermark, hotlink protection format\n        duration:\n          type: string\n          description: Total video duration, unit - s (seconds)\n    KlingImageGenerationsRequest:\n      type: object\n      properties:\n        model_name:\n          $ref: \"#/components/schemas/KlingImageGenModelName\"\n        prompt:\n          type: string\n          maxLength: 2500\n          description: Positive text prompt. Must not exceed 2,500 characters.\n        negative_prompt:\n          type: string\n          maxLength: 2500\n          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).\n        image:\n          type: string\n          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.\n        image_reference:\n          $ref: \"#/components/schemas/KlingImageGenImageReferenceType\"\n        image_fidelity:\n          type: number\n          minimum: 0\n          maximum: 1\n          default: 0.5\n          description: Reference intensity for user-uploaded images\n        human_fidelity:\n          type: number\n          minimum: 0\n          maximum: 1\n          default: 0.45\n          description: Subject reference similarity\n        element_list:\n          type: array\n          description: Reference Element List based on element ID configuration. The sum of reference elements and reference images shall not exceed 10.\n          items:\n            type: object\n            properties:\n              element_id:\n                type: integer\n                format: int64\n                description: Element ID\n        resolution:\n          type: string\n          enum: [\"1k\", \"2k\"]\n          default: \"1k\"\n          description: Image generation resolution. 1k is 1K standard, 2k is 2K high-res.\n        n:\n          type: integer\n          minimum: 1\n          maximum: 9\n          default: 1\n          description: Number of generated images. Value range [1,9].\n        aspect_ratio:\n          $ref: \"#/components/schemas/KlingImageGenAspectRatio\"\n        callback_url:\n          type: string\n          format: uri\n          description: The callback notification address\n        external_task_id:\n          type: string\n          description: Customized Task ID. Must be unique within a single user account.\n      required:\n        - prompt\n    KlingImageGenerationsResponse:\n      type: object\n      properties:\n        code:\n          type: integer\n          description: Error code\n        message:\n          type: string\n          description: Error message\n        request_id:\n          type: string\n          description: Request ID\n        data:\n          type: object\n          properties:\n            task_id:\n              type: string\n              description: Task ID\n            task_status:\n              $ref: \"#/components/schemas/KlingTaskStatus\"\n            task_status_msg:\n              type: string\n              description: Task status information, displaying the failure reason when the task fails\n            final_unit_deduction:\n              type: string\n              description: The deduction units of task\n            created_at:\n              type: integer\n              description: Task creation time, Unix timestamp in milliseconds\n            updated_at:\n              type: integer\n              description: Task update time, Unix timestamp in milliseconds\n            task_result:\n              type: object\n              properties:\n                images:\n                  type: array\n                  items:\n                    $ref: \"#/components/schemas/KlingImageResult\"\n            task_info:\n              type: object\n              properties:\n                external_task_id:\n                  type: string\n                  description: Customer-defined task ID\n    KlingVirtualTryOnRequest:\n      type: object\n      properties:\n        model_name:\n          $ref: \"#/components/schemas/KlingVirtualTryOnModelName\"\n        human_image:\n          type: string\n          description: Reference human image - Base64 encoded string or image URL\n        cloth_image:\n          type: string\n          description: Reference clothing image - Base64 encoded string or image URL\n        callback_url:\n          type: string\n          format: uri\n          description: The callback notification address\n      required:\n        - human_image\n    KlingVirtualTryOnResponse:\n      type: object\n      properties:\n        code:\n          type: integer\n          description: Error code\n        message:\n          type: string\n          description: Error message\n        request_id:\n          type: string\n          description: Request ID\n        data:\n          type: object\n          properties:\n            task_id:\n              type: string\n              description: Task ID\n            task_status:\n              $ref: \"#/components/schemas/KlingTaskStatus\"\n            task_status_msg:\n              type: string\n              description: Task status information\n            created_at:\n              type: integer\n              description: Task creation time\n            updated_at:\n              type: integer\n              description: Task update time\n            task_result:\n              type: object\n              properties:\n                images:\n                  type: array\n                  items:\n                    $ref: \"#/components/schemas/KlingImageResult\"\n    KlingResourcePackageResponse:\n      type: object\n      properties:\n        code:\n          type: integer\n          description: Error code; 0 indicates success\n        message:\n          type: string\n          description: Error information\n        request_id:\n          type: string\n          description: Request ID, generated by the system, used to track requests and troubleshoot problems\n        data:\n          type: object\n          properties:\n            code:\n              type: integer\n              description: Error code; 0 indicates success\n            msg:\n              type: string\n              description: Error information\n            resource_pack_subscribe_infos:\n              type: array\n              description: Resource package list\n              items:\n                type: object\n                properties:\n                  resource_pack_name:\n                    type: string\n                    description: Resource package name\n                  resource_pack_id:\n                    type: string\n                    description: Resource package ID\n                  resource_pack_type:\n                    type: string\n                    description: Resource package type (decreasing_total=decreasing total, constant_period=constant periodicity)\n                    enum: [decreasing_total, constant_period]\n                  total_quantity:\n                    type: number\n                    format: float\n                    description: Total quantity\n                  remaining_quantity:\n                    type: number\n                    format: float\n                    description: Remaining quantity (updated with a 12-hour delay)\n                  purchase_time:\n                    type: integer\n                    format: int64\n                    description: Purchase time, Unix timestamp in ms\n                  effective_time:\n                    type: integer\n                    format: int64\n                    description: Effective time, Unix timestamp in ms\n                  invalid_time:\n                    type: integer\n                    format: int64\n                    description: Expiration time, Unix timestamp in ms\n                  status:\n                    type: string\n                    description: Resource Package Status\n                    enum: [toBeOnline, online, expired, runOut]\n    LTXText2VideoRequest:\n      type: object\n      properties:\n        prompt:\n          type: string\n          maxLength: 10000\n          description: Text prompt describing the desired video content\n        model:\n          type: string\n          enum: [ltx-2-fast, ltx-2-pro]\n          description: Model to use for generation\n        duration:\n          type: integer\n          description: Video duration in seconds\n          enum: [6, 8, 10]\n        resolution:\n          type: string\n          enum: [1920x1080, 2560x1440, 3840x2160]\n          description: Output video resolution\n        fps:\n          type: integer\n          description: Frame rate in frames per second\n          default: 25\n          enum: [25, 50]\n        generate_audio:\n          type: boolean\n          description: Generate audio for the video\n          default: true\n      required:\n        - prompt\n        - model\n        - duration\n        - resolution\n    LTXImage2VideoRequest:\n      type: object\n      properties:\n        image_uri:\n          type: string\n          description: Image to be used as the first frame of the video (HTTPS URL or base64 data URI)\n        prompt:\n          type: string\n          maxLength: 10000\n          description: Text description of how the image should be animated\n        model:\n          type: string\n          enum: [ltx-2-fast, ltx-2-pro]\n          description: Model to use for generation\n        duration:\n          type: integer\n          description: Video duration in seconds\n          enum: [6, 8, 10]\n        resolution:\n          type: string\n          enum: [1920x1080, 2560x1440, 3840x2160]\n          description: Output video resolution\n        fps:\n          type: integer\n          description: Frame rate in frames per second\n          default: 25\n          enum: [25, 50]\n        generate_audio:\n          type: boolean\n          description: Generate audio for the video\n          default: true\n      required:\n        - image_uri\n        - prompt\n        - model\n        - duration\n        - resolution\n    StripeEvent:\n      type: object\n      required: [id, object, type, data]\n      properties:\n        id:\n          type: string\n        object:\n          type: string\n          enum: [\"event\"]\n        data:\n          type: object\n          properties:\n            object:\n              type: object\n        type:\n          type: string\n          enum: [invoice.paid]\n    MinimaxVideoGenerationRequest:\n      type: object\n      description: Parameters for the Minimax video generation proxy request.\n      properties:\n        model:\n          type: string\n          description: \"Required. ID of model. Options: MiniMax-Hailuo-02, T2V-01-Director, I2V-01-Director, S2V-01, I2V-01, I2V-01-live, T2V-01\"\n          enum:\n            - MiniMax-Hailuo-02\n            - T2V-01-Director\n            - I2V-01-Director\n            - S2V-01\n            - I2V-01\n            - I2V-01-live\n            - T2V-01\n        prompt:\n          type: string\n          description: \"Description of the video. Should be less than 2000 characters. Supports camera movement instructions in [brackets].\"\n          maxLength: 2000\n        prompt_optimizer:\n          type: boolean\n          description: \"If true (default), the model will automatically optimize the prompt. Set to false for more precise control.\"\n          default: true\n        first_frame_image:\n          type: string\n          description: \"URL or base64 encoding of the first frame image. Required when model is I2V-01, I2V-01-Director, or I2V-01-live.\"\n        subject_reference:\n          type: array\n          description: \"Only available when model is S2V-01. The model will generate a video based on the subject uploaded through this parameter.\"\n          items:\n            type: object\n            properties:\n              image:\n                type: string\n                description: \"URL or base64 encoding of the subject reference image.\"\n              mask:\n                type: string\n                description: \"URL or base64 encoding of the mask for the subject reference image.\"\n        callback_url:\n          type: string\n          description: \"Optional. URL to receive real-time status updates about the video generation task.\"\n        duration:\n          type: integer\n          description: \"Video length in seconds. Only available for MiniMax-Hailuo-02\"\n          enum: [6, 10]\n          default: 6\n        resolution:\n          type: string\n          description: \"Video resolution. Only available for MiniMax-Hailuo-02.\"\n          enum: [\"768P\", \"1080P\"]\n          default: \"768P\"\n      required:\n        - model\n\n    MinimaxBaseResponse:\n      type: object\n      description: Common response structure used by Minimax APIs\n      properties:\n        status_code:\n          type: integer\n          description: \"Status code. 0 indicates success, other values indicate errors.\"\n        status_msg:\n          type: string\n          description: \"Specific error details or success message.\"\n      required:\n        - status_code\n        - status_msg\n\n    MinimaxVideoGenerationResponse:\n      type: object\n      description: Response from the Minimax video generation API.\n      properties:\n        task_id:\n          type: string\n          description: \"The task ID for the asynchronous video generation task.\"\n        base_resp:\n          $ref: \"#/components/schemas/MinimaxBaseResponse\"\n      required:\n        - task_id\n        - base_resp\n    MinimaxFileRetrieveResponse:\n      type: object\n      description: Response from retrieving a Minimax file download URL.\n      properties:\n        file:\n          type: object\n          properties:\n            file_id:\n              type: integer\n              description: Unique identifier for the file\n            bytes:\n              type: integer\n              description: File size in bytes\n            created_at:\n              type: integer\n              description: Unix timestamp when the file was created, in seconds\n            filename:\n              type: string\n              description: The name of the file\n            purpose:\n              type: string\n              description: The purpose of using the file\n            download_url:\n              type: string\n              description: The URL to download the video\n        base_resp:\n          $ref: \"#/components/schemas/MinimaxBaseResponse\"\n      required:\n        - file\n        - base_resp\n    MinimaxTaskResultResponse:\n      type: object\n      description: Response from querying a Minimax video generation task status.\n      properties:\n        task_id:\n          type: string\n          description: \"The task ID being queried.\"\n        status:\n          type: string\n          description: \"Task status: 'Queueing' (in queue), 'Preparing' (task is preparing), 'Processing' (generating), 'Success' (task completed successfully), or 'Fail' (task failed).\"\n          enum:\n            - Queueing\n            - Preparing\n            - Processing\n            - Success\n            - Fail\n        file_id:\n          type: string\n          description: \"After the task status changes to Success, this field returns the file ID corresponding to the generated video.\"\n        base_resp:\n          $ref: \"#/components/schemas/MinimaxBaseResponse\"\n      required:\n        - task_id\n        - status\n        - base_resp\n    BFLFluxKontextProGenerateRequest:\n      type: object\n      required:\n        - prompt\n        - input_image\n      properties:\n        prompt:\n          type: string\n          description: The text prompt describing what to edit on the image\n        input_image:\n          type: string\n          description: Base64 encoded image to be edited\n        steps:\n          type: integer\n          description: Number of inference steps\n          minimum: 1\n          maximum: 50\n          default: 50\n        guidance:\n          type: number\n          description: The guidance scale for generation\n          minimum: 1.0\n          maximum: 20.0\n          default: 3.0\n    BFLFluxKontextProGenerateResponse:\n      type: object\n      required:\n        - id\n        - polling_url\n      properties:\n        id:\n          type: string\n          description: Job ID for tracking\n        polling_url:\n          type: string\n          description: URL to poll for results\n    BFLFluxKontextMaxGenerateRequest:\n      type: object\n      required:\n        - prompt\n        - input_image\n      properties:\n        prompt:\n          type: string\n          description: The text prompt describing what to edit on the image\n        input_image:\n          type: string\n          description: Base64 encoded image to be edited\n        steps:\n          type: integer\n          description: Number of inference steps\n          minimum: 1\n          maximum: 50\n          default: 50\n        guidance:\n          type: number\n          description: The guidance scale for generation\n          minimum: 1.0\n          maximum: 20.0\n          default: 3.0\n    BFLFluxKontextMaxGenerateResponse:\n      type: object\n      required:\n        - id\n        - polling_url\n      properties:\n        id:\n          type: string\n          description: Job ID for tracking\n        polling_url:\n          type: string\n          description: URL to poll for results\n    BFLFluxPro1_1GenerateRequest:\n      type: object\n      required:\n        - prompt\n        - width\n        - height\n      properties:\n        prompt:\n          type: string\n          description: The main text prompt for image generation\n        image_prompt:\n          type: string\n          description: Optional image prompt\n        width:\n          type: integer\n          description: Width of the generated image\n        height:\n          type: integer\n          description: Height of the generated image\n        prompt_upsampling:\n          type: boolean\n          description: Whether to use prompt upsampling\n        seed:\n          type: integer\n          description: Random seed for reproducibility\n        safety_tolerance:\n          type: integer\n          description: Safety tolerance level\n        output_format:\n          type: string\n          enum: [jpeg, png]\n          description: Output image format\n        webhook_url:\n          type: string\n          description: Optional webhook URL for async processing\n        webhook_secret:\n          type: string\n          description: Optional webhook secret for async processing\n\n    BFLFluxPro1_1GenerateResponse:\n      type: object\n      required:\n        - id\n        - polling_url\n      properties:\n        id:\n          type: string\n          description: Job ID for tracking\n        polling_url:\n          type: string\n          description: URL to poll for results\n    BFLFluxProGenerateRequest:\n      type: object\n      description: Request body for the BFL Flux Pro 1.1 Ultra image generation API.\n      properties:\n        prompt:\n          type: string\n          description: The text prompt for image generation.\n        negative_prompt:\n          type: string\n          description: The negative prompt for image generation.\n        width:\n          type: integer\n          description: The width of the image to generate.\n          minimum: 64\n          maximum: 2048\n        height:\n          type: integer\n          description: The height of the image to generate.\n          minimum: 64\n          maximum: 2048\n        num_inference_steps:\n          type: integer\n          description: The number of inference steps.\n          minimum: 1\n          maximum: 100\n        guidance_scale:\n          type: number\n          description: The guidance scale for generation.\n          minimum: 1.0\n          maximum: 20.0\n        seed:\n          type: integer\n          description: The seed value for reproducibility.\n        num_images:\n          type: integer\n          description: The number of images to generate.\n          minimum: 1\n          maximum: 4\n      required:\n        - prompt\n        - width\n        - height\n\n    BFLFluxProGenerateResponse:\n      type: object\n      description: Response from the BFL Flux Pro 1.1 Ultra image generation API.\n      properties:\n        id:\n          type: string\n          description: The unique identifier for the generation task.\n        polling_url:\n          type: string\n          description: URL to poll for the generation result.\n        cost:\n          type: number\n          format: float\n          description: The cost of the generation task.\n        input_mp:\n          type: number\n          format: float\n          description: Input megapixels.\n        output_mp:\n          type: number\n          format: float\n          description: Output megapixels.\n      required:\n        - id\n        - polling_url\n    BFLFluxProStatusResponse:\n      type: object\n      description: Response from the BFL Flux Pro 1.1 Ultra status check API.\n      properties:\n        id:\n          type: string\n          description: The unique identifier for the generation task.\n        status:\n          $ref: \"#/components/schemas/BFLStatus\"\n          description: The status of the task.\n        result:\n          type: object\n          description: The result of the task (null if not completed).\n          nullable: true\n        progress:\n          type: number\n          format: float\n          description: The progress of the task (0.0 to 1.0).\n          minimum: 0.0\n          maximum: 1.0\n        details:\n          type: object\n          description: Additional details about the task (null if not available).\n          nullable: true\n      required:\n        - id\n        - status\n        - progress\n    BFLStatus:\n      type: string\n      description: Possible statuses for a BFL Flux Pro generation task.\n      enum:\n        - Task not found\n        - Pending\n        - Request Moderated\n        - Content Moderated\n        - Ready\n        - Error\n      example: Ready\n    BFLFlux2ProGenerateRequest:\n      type: object\n      description: Request body for the BFL Flux 2 Pro image generation API.\n      properties:\n        prompt:\n          type: string\n          description: Text description of the image to generate.\n        input_image:\n          type: string\n          description: Base64 encoded image for image-to-image generation.\n        input_image_2:\n          type: string\n          description: Base64 encoded image for image-to-image generation.\n        input_image_3:\n          type: string\n          description: Base64 encoded image for image-to-image generation.\n        input_image_4:\n          type: string\n          description: Base64 encoded image for image-to-image generation.\n        input_image_5:\n          type: string\n          description: Base64 encoded image for image-to-image generation.\n        input_image_6:\n          type: string\n          description: Base64 encoded image for image-to-image generation.\n        input_image_7:\n          type: string\n          description: Base64 encoded image for image-to-image generation.\n        input_image_8:\n          type: string\n          description: Base64 encoded image for image-to-image generation.\n        input_image_9:\n          type: string\n          description: Base64 encoded image for image-to-image generation.\n        width:\n          type: integer\n          description: Width of the image.\n          default: 1024\n          minimum: 256\n          maximum: 2048\n        height:\n          type: integer\n          description: Height of the image.\n          default: 1024\n          minimum: 256\n          maximum: 2048\n        seed:\n          type: integer\n          description: Seed for reproducibility.\n        prompt_upsampling:\n          type: boolean\n          description: Automatically modify prompt for generation.\n          default: true\n        output_format:\n          type: string\n          description: Output format for the generated image.\n          default: jpeg\n          enum:\n            - jpeg\n            - png\n        safety_tolerance:\n          type: integer\n          description: Moderation tolerance level (Flux 2 Max only).\n          default: 2\n          minimum: 0\n          maximum: 5\n      required:\n        - prompt\n    BFLFluxProFillInputs:\n      properties:\n        image:\n          type: string\n          title: Image\n          description: >-\n            A Base64-encoded string representing the image you wish to modify. Can contain alpha mask if desired.\n        mask:\n          anyOf:\n            - type: string\n          title: Mask\n          description: >-\n            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.\n        prompt:\n          anyOf:\n            - type: string\n          title: Prompt\n          description: >-\n            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.\n          default: \"\"\n          example: ein fantastisches bild\n        steps:\n          anyOf:\n            - type: integer\n              maximum: 50\n              minimum: 15\n          title: Steps\n          description: Number of steps for the image generation process\n          default: 50\n          example: 50\n        prompt_upsampling:\n          anyOf:\n            - type: boolean\n          title: Prompt Upsampling\n          description: >-\n            Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation\n          default: false\n        seed:\n          anyOf:\n            - type: integer\n          title: Seed\n          description: Optional seed for reproducibility\n        guidance:\n          anyOf:\n            - type: number\n              maximum: 100\n              minimum: 1.5\n          title: Guidance\n          description: Guidance strength for the image generation process\n          default: 60\n        output_format:\n          anyOf:\n            - $ref: \"#/components/schemas/BFLOutputFormat\"\n          description: Output format for the generated image. Can be 'jpeg' or 'png'.\n          default: jpeg\n        safety_tolerance:\n          type: integer\n          maximum: 6\n          minimum: 0\n          title: Safety Tolerance\n          description: >-\n            Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict.\n          default: 2\n          example: 2\n        webhook_url:\n          anyOf:\n            - type: string\n              maxLength: 2083\n              minLength: 1\n              format: uri\n          title: Webhook Url\n          description: URL to receive webhook notifications\n        webhook_secret:\n          anyOf:\n            - type: string\n          title: Webhook Secret\n          description: Optional secret for webhook signature verification\n      type: object\n      required:\n        - image\n      title: FluxProFillInputs\n    BFLAsyncResponse:\n      properties:\n        id:\n          type: string\n          title: Id\n        polling_url:\n          type: string\n          title: Polling Url\n      type: object\n      required:\n        - id\n        - polling_url\n      title: AsyncResponse\n    BFLAsyncWebhookResponse:\n      properties:\n        id:\n          type: string\n          title: Id\n        status:\n          type: string\n          title: Status\n        webhook_url:\n          type: string\n          title: Webhook Url\n      type: object\n      required:\n        - id\n        - status\n        - webhook_url\n      title: AsyncWebhookResponse\n    BFLHTTPValidationError:\n      properties:\n        detail:\n          items:\n            $ref: \"#/components/schemas/BFLValidationError\"\n          type: array\n          title: Detail\n      type: object\n      title: HTTPValidationError\n    BFLFluxProExpandInputs:\n      properties:\n        image:\n          type: string\n          title: Image\n          description: A Base64-encoded string representing the image you wish to expand.\n        top:\n          anyOf:\n            - type: integer\n              maximum: 2048\n              minimum: 0\n          title: Top\n          description: Number of pixels to expand at the top of the image\n          default: 0\n        bottom:\n          anyOf:\n            - type: integer\n              maximum: 2048\n              minimum: 0\n          title: Bottom\n          description: Number of pixels to expand at the bottom of the image\n          default: 0\n        left:\n          anyOf:\n            - type: integer\n              maximum: 2048\n              minimum: 0\n          title: Left\n          description: Number of pixels to expand on the left side of the image\n          default: 0\n        right:\n          anyOf:\n            - type: integer\n              maximum: 2048\n              minimum: 0\n          title: Right\n          description: Number of pixels to expand on the right side of the image\n          default: 0\n        prompt:\n          anyOf:\n            - type: string\n          title: Prompt\n          description: >-\n            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.\n          default: \"\"\n          example: ein fantastisches bild\n        steps:\n          anyOf:\n            - type: integer\n              maximum: 50\n              minimum: 15\n          title: Steps\n          description: Number of steps for the image generation process\n          default: 50\n          example: 50\n        prompt_upsampling:\n          anyOf:\n            - type: boolean\n          title: Prompt Upsampling\n          description: >-\n            Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation\n          default: false\n        seed:\n          anyOf:\n            - type: integer\n          title: Seed\n          description: Optional seed for reproducibility\n        guidance:\n          anyOf:\n            - type: number\n              maximum: 100\n              minimum: 1.5\n          title: Guidance\n          description: Guidance strength for the image generation process\n          default: 60\n        output_format:\n          anyOf:\n            - $ref: \"#/components/schemas/BFLOutputFormat\"\n          description: Output format for the generated image. Can be 'jpeg' or 'png'.\n          default: jpeg\n        safety_tolerance:\n          type: integer\n          maximum: 6\n          minimum: 0\n          title: Safety Tolerance\n          description: >-\n            Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict.\n          default: 2\n          example: 2\n        webhook_url:\n          anyOf:\n            - type: string\n              maxLength: 2083\n              minLength: 1\n              format: uri\n          title: Webhook Url\n          description: URL to receive webhook notifications\n        webhook_secret:\n          anyOf:\n            - type: string\n          title: Webhook Secret\n          description: Optional secret for webhook signature verification\n      type: object\n      required:\n        - image\n      title: FluxProExpandInputs\n    BFLCannyInputs:\n      properties:\n        prompt:\n          type: string\n          title: Prompt\n          description: Text prompt for image generation\n          example: ein fantastisches bild\n        control_image:\n          anyOf:\n            - type: string\n          title: Control Image\n          description: >-\n            Base64 encoded image to use as control input if no preprocessed image is provided\n        preprocessed_image:\n          anyOf:\n            - type: string\n          title: Preprocessed Image\n          description: >-\n            Optional pre-processed image that will bypass the control preprocessing step\n        canny_low_threshold:\n          anyOf:\n            - type: integer\n              maximum: 500\n              minimum: 0\n          title: Canny Low Threshold\n          description: Low threshold for Canny edge detection\n          default: 50\n        canny_high_threshold:\n          anyOf:\n            - type: integer\n              maximum: 500\n              minimum: 0\n          title: Canny High Threshold\n          description: High threshold for Canny edge detection\n          default: 200\n        prompt_upsampling:\n          anyOf:\n            - type: boolean\n          title: Prompt Upsampling\n          description: Whether to perform upsampling on the prompt\n          default: false\n        seed:\n          anyOf:\n            - type: integer\n          title: Seed\n          description: Optional seed for reproducibility\n          example: 42\n        steps:\n          anyOf:\n            - type: integer\n              maximum: 50\n              minimum: 15\n          title: Steps\n          description: Number of steps for the image generation process\n          default: 50\n        output_format:\n          anyOf:\n            - $ref: \"#/components/schemas/BFLOutputFormat\"\n          description: Output format for the generated image. Can be 'jpeg' or 'png'.\n          default: jpeg\n        guidance:\n          anyOf:\n            - type: number\n              maximum: 100\n              minimum: 1\n          title: Guidance\n          description: Guidance strength for the image generation process\n          default: 30\n        safety_tolerance:\n          type: integer\n          maximum: 6\n          minimum: 0\n          title: Safety Tolerance\n          description: >-\n            Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict.\n          default: 2\n        webhook_url:\n          anyOf:\n            - type: string\n              maxLength: 2083\n              minLength: 1\n              format: uri\n          title: Webhook Url\n          description: URL to receive webhook notifications\n        webhook_secret:\n          anyOf:\n            - type: string\n          title: Webhook Secret\n          description: Optional secret for webhook signature verification\n      type: object\n      required:\n        - prompt\n      title: CannyInputs\n    BFLDepthInputs:\n      properties:\n        prompt:\n          type: string\n          title: Prompt\n          description: Text prompt for image generation\n          example: ein fantastisches bild\n        control_image:\n          anyOf:\n            - type: string\n          title: Control Image\n          description: Base64 encoded image to use as control input\n        preprocessed_image:\n          anyOf:\n            - type: string\n          title: Preprocessed Image\n          description: >-\n            Optional pre-processed image that will bypass the control preprocessing step\n        prompt_upsampling:\n          anyOf:\n            - type: boolean\n          title: Prompt Upsampling\n          description: Whether to perform upsampling on the prompt\n          default: false\n        seed:\n          anyOf:\n            - type: integer\n          title: Seed\n          description: Optional seed for reproducibility\n          example: 42\n        steps:\n          anyOf:\n            - type: integer\n              maximum: 50\n              minimum: 15\n          title: Steps\n          description: Number of steps for the image generation process\n          default: 50\n        output_format:\n          anyOf:\n            - $ref: \"#/components/schemas/BFLOutputFormat\"\n          description: Output format for the generated image. Can be 'jpeg' or 'png'.\n          default: jpeg\n        guidance:\n          anyOf:\n            - type: number\n              maximum: 100\n              minimum: 1\n          title: Guidance\n          description: Guidance strength for the image generation process\n          default: 15\n        safety_tolerance:\n          type: integer\n          maximum: 6\n          minimum: 0\n          title: Safety Tolerance\n          description: >-\n            Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict.\n          default: 2\n        webhook_url:\n          anyOf:\n            - type: string\n              maxLength: 2083\n              minLength: 1\n              format: uri\n          title: Webhook Url\n          description: URL to receive webhook notifications\n        webhook_secret:\n          anyOf:\n            - type: string\n          title: Webhook Secret\n          description: Optional secret for webhook signature verification\n      type: object\n      required:\n        - prompt\n      title: DepthInputs\n    BFLOutputFormat:\n      type: string\n      enum:\n        - jpeg\n        - png\n      title: OutputFormat\n    BFLValidationError:\n      properties:\n        loc:\n          items:\n            anyOf:\n              - type: string\n              - type: integer\n          type: array\n          title: Location\n        msg:\n          type: string\n          title: Message\n        type:\n          type: string\n          title: Error Type\n      type: object\n      required:\n        - loc\n        - msg\n        - type\n      title: ValidationError\n\n    RecraftImageGenerationRequest:\n      type: object\n      description: Parameters for the Recraft image generation proxy request.\n      properties:\n        prompt:\n          type: string\n          description: The text prompt describing the image to generate\n        model:\n          type: string\n          description: The model to use for generation (e.g., \"recraftv3\")\n        style:\n          type: string\n          description: The style to apply to the generated image (e.g., \"digital_illustration\")\n        style_id:\n          type: string\n          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.\n        size:\n          type: string\n          description: The size of the generated image (e.g., \"1024x1024\")\n        controls:\n          type: object\n          description: The controls for the generated image\n          properties:\n            artistic_level:\n              type: integer\n              nullable: true\n              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.\n              minimum: 0\n              maximum: 5\n            colors:\n              type: array\n              description: An array of preferable colors\n              items:\n                $ref: \"#/components/schemas/RGBColor\"\n            background_color:\n              $ref: \"#/components/schemas/RGBColor\"\n              description: Use given color as a desired background color\n            no_text:\n              type: boolean\n              description: Do not embed text layouts\n\n        n:\n          type: integer\n          description: The number of images to generate\n          minimum: 1\n          maximum: 4\n      required:\n        - prompt\n        - model\n        - size\n        - n\n\n    RecraftImageGenerationResponse:\n      type: object\n      description: Response from the Recraft image generation API.\n      properties:\n        created:\n          type: integer\n          description: Unix timestamp when the generation was created\n        credits:\n          type: integer\n          description: Number of credits used for the generation\n        data:\n          type: array\n          description: Array of generated image information\n          items:\n            type: object\n            properties:\n              image_id:\n                type: string\n                description: Unique identifier for the generated image\n              url:\n                type: string\n                description: URL to access the generated image\n      required:\n        - created\n        - credits\n        - data\n    RecraftImageFeatures:\n      properties:\n        nsfw_score:\n          type: number\n      type: object\n    RecraftTextLayoutItem:\n      properties:\n        bbox:\n          items:\n            items:\n              type: number\n              x-go-type: float32\n            type: array\n          type: array\n        text:\n          type: string\n      required:\n        - text\n        - bbox\n      type: object\n    RecraftImageColor:\n      properties:\n        rgb:\n          items:\n            type: integer\n          type: array\n        std:\n          items:\n            type: number\n          type: array\n        weight:\n          type: number\n      type: object\n    RecraftImageStyle:\n      enum:\n        - digital_illustration\n        - icon\n        - realistic_image\n        - vector_illustration\n      type: string\n    RecraftImageSubStyle:\n      enum:\n        - 2d_art_poster\n        - 3d\n        - 80s\n        - glow\n        - grain\n        - hand_drawn\n        - infantile_sketch\n        - kawaii\n        - pixel_art\n        - psychedelic\n        - seamless\n        - voxel\n        - watercolor\n        - broken_line\n        - colored_outline\n        - colored_shapes\n        - colored_shapes_gradient\n        - doodle_fill\n        - doodle_offset_fill\n        - offset_fill\n        - outline\n        - outline_gradient\n        - uneven_fill\n        - 70s\n        - cartoon\n        - doodle_line_art\n        - engraving\n        - flat_2\n        - kawaii\n        - line_art\n        - linocut\n        - seamless\n        - b_and_w\n        - enterprise\n        - hard_flash\n        - hdr\n        - motion_blur\n        - natural_light\n        - studio_portrait\n        - line_circuit\n        - 2d_art_poster_2\n        - engraving_color\n        - flat_air_art\n        - hand_drawn_outline\n        - handmade_3d\n        - stickers_drawings\n        - plastic\n        - pictogram\n      type: string\n    RecraftTransformModel:\n      enum:\n        - refm1\n        - recraft20b\n        - recraftv2\n        - recraftv3\n        - recraftv4\n        - recraftv4_pro\n        - flux1_1pro\n        - flux1dev\n        - imagen3\n        - hidream_i1_dev\n      type: string\n    RecraftImageFormat:\n      enum:\n        - webp\n        - png\n      type: string\n    RecraftResponseFormat:\n      enum:\n        - url\n        - b64_json\n      type: string\n    RecraftImage:\n      properties:\n        b64_json:\n          type: string\n        features:\n          $ref: \"#/components/schemas/RecraftImageFeatures\"\n        image_id:\n          format: uuid\n          type: string\n        revised_prompt:\n          type: string\n        url:\n          type: string\n      required:\n        - image_id\n      type: object\n    RecraftUserControls:\n      properties:\n        artistic_level:\n          type: integer\n        background_color:\n          $ref: \"#/components/schemas/RecraftImageColor\"\n        colors:\n          items:\n            $ref: \"#/components/schemas/RecraftImageColor\"\n          type: array\n        no_text:\n          type: boolean\n      type: object\n    RecraftTextLayout:\n      items:\n        $ref: \"#/components/schemas/RecraftTextLayoutItem\"\n      type: array\n    RecraftProcessImageRequest:\n      properties:\n        image:\n          format: binary\n          type: string\n        image_format:\n          $ref: \"#/components/schemas/RecraftImageFormat\"\n        response_format:\n          $ref: \"#/components/schemas/RecraftResponseFormat\"\n      required:\n        - image\n      type: object\n    RecraftProcessImageResponse:\n      properties:\n        created:\n          type: integer\n        credits:\n          type: integer\n        image:\n          $ref: \"#/components/schemas/RecraftImage\"\n      required:\n        - created\n        - image\n        - credits\n      type: object\n    RecraftImageToImageRequest:\n      properties:\n        block_nsfw:\n          type: boolean\n        calculate_features:\n          type: boolean\n        controls:\n          $ref: \"#/components/schemas/RecraftUserControls\"\n        image:\n          format: binary\n          type: string\n        image_format:\n          $ref: \"#/components/schemas/RecraftImageFormat\"\n        model:\n          $ref: \"#/components/schemas/RecraftTransformModel\"\n        \"n\":\n          type: integer\n        negative_prompt:\n          type: string\n        prompt:\n          type: string\n        response_format:\n          $ref: \"#/components/schemas/RecraftResponseFormat\"\n        strength:\n          type: number\n        style:\n          $ref: \"#/components/schemas/RecraftImageStyle\"\n        style_id:\n          format: uuid\n          type: string\n        substyle:\n          $ref: \"#/components/schemas/RecraftImageSubStyle\"\n        text_layout:\n          $ref: \"#/components/schemas/RecraftTextLayout\"\n      required:\n        - prompt\n        - image\n        - strength\n      type: object\n    RecraftGenerateImageResponse:\n      properties:\n        created:\n          type: integer\n        credits:\n          type: integer\n        data:\n          items:\n            $ref: \"#/components/schemas/RecraftImage\"\n          type: array\n      required:\n        - created\n        - data\n        - credits\n      type: object\n    RecraftTransformImageWithMaskRequest:\n      properties:\n        block_nsfw:\n          type: boolean\n        calculate_features:\n          type: boolean\n        image:\n          format: binary\n          type: string\n        image_format:\n          $ref: \"#/components/schemas/RecraftImageFormat\"\n        mask:\n          format: binary\n          type: string\n        model:\n          $ref: \"#/components/schemas/RecraftTransformModel\"\n        \"n\":\n          type: integer\n        negative_prompt:\n          type: string\n        prompt:\n          type: string\n        response_format:\n          $ref: \"#/components/schemas/RecraftResponseFormat\"\n        style:\n          $ref: \"#/components/schemas/RecraftImageStyle\"\n        style_id:\n          format: uuid\n          type: string\n        substyle:\n          $ref: \"#/components/schemas/RecraftImageSubStyle\"\n        text_layout:\n          $ref: \"#/components/schemas/RecraftTextLayout\"\n      required:\n        - image\n        - mask\n        - prompt\n      type: object\n    RecraftCreateStyleRequest:\n      type: object\n      description: Request body for creating a Recraft style reference\n      properties:\n        style:\n          type: string\n          description: The base style of the generated images\n          enum:\n            - realistic_image\n            - digital_illustration\n            - vector_illustration\n            - icon\n        file1:\n          type: string\n          format: binary\n          description: First image file (PNG, JPG, or WEBP)\n        file2:\n          type: string\n          format: binary\n          description: Second image file (PNG, JPG, or WEBP)\n        file3:\n          type: string\n          format: binary\n          description: Third image file (PNG, JPG, or WEBP)\n        file4:\n          type: string\n          format: binary\n          description: Fourth image file (PNG, JPG, or WEBP)\n        file5:\n          type: string\n          format: binary\n          description: Fifth image file (PNG, JPG, or WEBP)\n      required:\n        - style\n        - file1\n    RecraftCreateStyleResponse:\n      type: object\n      description: Response containing the created style ID\n      properties:\n        id:\n          type: string\n          format: uuid\n          description: The unique identifier of the created style\n      required:\n        - id\n    TencentHunyuan3DProRequest:\n      type: object\n      description: Request body for Tencent Hunyuan 3D Pro generation\n      properties:\n        Model:\n          type: string\n          description: |\n            Tencent HY 3D Global model version.\n            Defaults to 3.0, with optional choices: 3.0, 3.1.\n            When selecting version 3.1, the LowPoly parameter is unavailable.\n          enum: [\"3.0\", \"3.1\"]\n          default: \"3.0\"\n          example: \"3.0\"\n        Prompt:\n          type: string\n          description: |\n            Text description for 3D content generation.\n            Supports up to 1024 utf-8 characters.\n            Either Prompt or ImageBase64/ImageUrl is required, but not both.\n          maxLength: 1024\n          example: \"A cat\"\n        ImageBase64:\n          type: string\n          description: |\n            Base64 encoded image for image-to-3D generation.\n            Resolution: min 128px, max 5000px per side.\n            Max size: 8MB (recommend 6MB before encoding).\n            Supported formats: jpg, png, jpeg, webp.\n            Either ImageBase64/ImageUrl or Prompt is required.\n        ImageUrl:\n          type: string\n          format: uri\n          description: |\n            URL of input image for image-to-3D generation.\n            Resolution: min 128px, max 5000px per side.\n            Max size: 8MB.\n            Supported formats: jpg, png, jpeg, webp.\n            Either ImageBase64/ImageUrl or Prompt is required.\n        EnablePBR:\n          type: boolean\n          description: Whether to enable PBR material generation.\n          default: false\n        FaceCount:\n          type: integer\n          description: Face count for 3D model generation.\n          minimum: 40000\n          maximum: 1500000\n          default: 500000\n        GenerateType:\n          type: string\n          description: |\n            Generation task type:\n            - Normal: generates a geometric model with textures (default)\n            - LowPoly: model generated after intelligent polygon reduction\n            - Geometry: generate model without textures (white model)\n            - Sketch: generative model from sketch or line drawing\n          enum: [\"Normal\", \"LowPoly\", \"Geometry\", \"Sketch\"]\n          default: \"Normal\"\n        PolygonType:\n          type: string\n          description: |\n            Polygon type (only effective when GenerateType is LowPoly).\n            - triangle: triangular faces (default)\n            - quadrilateral: mix of quadrangle and triangle faces\n          enum: [\"triangle\", \"quadrilateral\"]\n          default: \"triangle\"\n        MultiViewImages:\n          type: array\n          description: |\n            Multi-perspective model images for 3D generation.\n            Each perspective is limited to one image.\n            Image size limit: max 8MB after encoding.\n            Image resolution: min 128px, max 5000px per side.\n            Supported formats: JPG, PNG.\n          items:\n            $ref: \"#/components/schemas/TencentViewImage\"\n    TencentViewImage:\n      type: object\n      description: A view image for multi-perspective 3D generation\n      properties:\n        ViewType:\n          type: string\n          description: |\n            The viewing angle type for this image.\n            - left: Left view\n            - right: Right view\n            - back: Rear view\n            - top: Top view (only supported in Model 3.1)\n            - bottom: Bottom view (only supported in Model 3.1)\n            - left_front: Left front 45 degree view (only supported in Model 3.1)\n            - right_front: Right front 45 degree view (only supported in Model 3.1)\n          enum: [\"left\", \"right\", \"back\", \"top\", \"bottom\", \"left_front\", \"right_front\"]\n        ViewImageBase64:\n          type: string\n          description: |\n            Base64 encoded image for this view.\n            Resolution: min 128px, max 5000px per side.\n            Max size: 8MB.\n            Supported formats: JPG, PNG.\n        ViewImageUrl:\n          type: string\n          format: uri\n          description: |\n            URL of the image for this view.\n            Resolution: min 128px, max 5000px per side.\n            Max size: 8MB.\n            Supported formats: JPG, PNG.\n    TencentHunyuan3DProResponse:\n      type: object\n      description: Response from Tencent Hunyuan 3D Pro submit endpoint\n      properties:\n        Response:\n          type: object\n          properties:\n            JobId:\n              type: string\n              description: Task ID (valid for 24 hours)\n              example: \"1375367755519696896\"\n            RequestId:\n              type: string\n              description: Unique request ID for troubleshooting\n              example: \"13f47dd0-1af9-4383-b401-dae18d6e99fc\"\n            Error:\n              type: object\n              description: Error object (present when request fails)\n              properties:\n                Code:\n                  type: string\n                  description: Error code\n                Message:\n                  type: string\n                  description: Error message\n    TencentHunyuan3DQueryRequest:\n      type: object\n      required:\n        - JobId\n      properties:\n        JobId:\n          type: string\n          description: The JobId returned from the submit endpoint\n          example: \"1375367755519696896\"\n    TencentHunyuan3DQueryResponse:\n      type: object\n      description: Response from Tencent Hunyuan 3D query endpoint\n      properties:\n        Response:\n          type: object\n          properties:\n            Status:\n              type: string\n              description: |\n                Task status:\n                - WAIT: waiting\n                - RUN: running\n                - FAIL: failed\n                - DONE: successful\n              enum: [\"WAIT\", \"RUN\", \"FAIL\", \"DONE\"]\n            ErrorCode:\n              type: string\n              description: Error code (empty string if no error)\n            ErrorMessage:\n              type: string\n              description: Error message if task failed (empty string if no error)\n            ResultFile3Ds:\n              type: array\n              description: Array of generated 3D files\n              items:\n                $ref: \"#/components/schemas/TencentFile3D\"\n            RequestId:\n              type: string\n              description: Unique request ID for troubleshooting\n    TencentFile3D:\n      type: object\n      description: 3D file information\n      properties:\n        Type:\n          type: string\n          description: 3D file format\n          enum: [\"GLB\", \"OBJ\"]\n        Url:\n          type: string\n          format: uri\n          description: File URL (valid for 24 hours)\n        PreviewImageUrl:\n          type: string\n          format: uri\n          description: Preview image URL\n    TencentErrorResponse:\n      type: object\n      description: Error response from Tencent API\n      properties:\n        Response:\n          type: object\n          properties:\n            Error:\n              type: object\n              properties:\n                Code:\n                  type: string\n                  description: Error code\n                Message:\n                  type: string\n                  description: Error message\n            RequestId:\n              type: string\n              description: Unique request ID for troubleshooting\n    TencentHunyuan3DUVRequest:\n      type: object\n      description: Request body for Tencent Hunyuan 3D UV unfolding\n      properties:\n        File:\n          $ref: \"#/components/schemas/TencentInputFile3D\"\n    TencentInputFile3D:\n      type: object\n      description: 3D file input for UV unwrapping\n      properties:\n        Type:\n          type: string\n          description: 3D file format type\n          enum: [\"FBX\", \"OBJ\", \"GLB\"]\n          example: \"GLB\"\n        Url:\n          type: string\n          format: uri\n          description: URL of the 3D file that needs UV unwrapping\n          example: \"https://example.com/model.glb\"\n      required:\n        - Type\n        - Url\n    TencentHunyuan3DUVResponse:\n      type: object\n      description: Response from Tencent Hunyuan 3D UV submit endpoint\n      properties:\n        Response:\n          type: object\n          properties:\n            JobId:\n              type: string\n              description: Task ID for the UV unwrapping job\n              example: \"1384898587778465792\"\n            RequestId:\n              type: string\n              description: Unique request ID for troubleshooting\n              example: \"5265eb4a-0f4f-4cb1-9b3d-d9f1fb9347d2\"\n            Error:\n              type: object\n              description: Error object (present when request fails)\n              properties:\n                Code:\n                  type: string\n                  description: Error code\n                Message:\n                  type: string\n                  description: Error message\n    TencentHunyuan3DTextureEditRequest:\n      type: object\n      description: Request body for Tencent Hunyuan 3D texture edit\n      required:\n        - File3D\n      properties:\n        File3D:\n          $ref: \"#/components/schemas/TencentInputFile3D\"\n          description: File URL of the 3D model that requires texture edit. Supported format FBX, less than 100000 faces.\n        Image:\n          $ref: \"#/components/schemas/TencentImageInfo\"\n          description: Reference image for 3D model texture editing. Either Base64 or Url must be provided. If both provided, Url prevails. Incompatible with Prompt.\n        Prompt:\n          type: string\n          maxLength: 1024\n          description: Describes texture editing. Either Image or Prompt is required; they cannot coexist.\n          example: \"a kitten\"\n        EnablePBR:\n          type: boolean\n          description: Whether to enable the PBR texture parameter; only supported when using Prompt.\n          example: true\n    TencentImageInfo:\n      type: object\n      description: Reference image - Base64 data or image URL\n      properties:\n        ImageBase64:\n          type: string\n          description: Base64 encoded image. Resolution 128-4096 per side, converted Base64 less than 10MB. Formats jpg, jpeg, png.\n        ImageUrl:\n          type: string\n          format: uri\n          description: Image URL. If both Base64 and Url provided, Url prevails.\n    TencentHunyuan3DSmartTopologyRequest:\n      type: object\n      description: Request body for Tencent Hunyuan 3D Smart Topology (retopology/polygon reduction)\n      required:\n        - File3D\n      properties:\n        File3D:\n          $ref: \"#/components/schemas/TencentInputFile3D\"\n          description: Source 3D file model link. Supported formats GLB, OBJ. File size max 200MB.\n        PolygonType:\n          type: string\n          description: Polygon type for the output mesh. Defaults to triangle.\n          enum: [\"triangle\", \"quadrilateral\"]\n          example: \"triangle\"\n        FaceLevel:\n          type: string\n          description: Polygon reduction level.\n          enum: [\"high\", \"medium\", \"low\"]\n          example: \"medium\"\n    HitPawPhotoEnhancerRequest:\n      type: object\n      description: Request body for HitPaw Photo Enhancement API\n      required:\n        - model_name\n        - img_url\n        - extension\n      properties:\n        model_name:\n          type: string\n          description: |\n            The model name to use for enhancement.\n            \n            **Available Models:**\n            - face_2x, face_4x: Face Clear Model (2x/4x upscaling)\n            - face_v2_2x, face_v2_4x: Face Natural Model (2x/4x upscaling)\n            - general_2x, general_4x: General Enhance Model (2x/4x upscaling)\n            - high_fidelity_2x, high_fidelity_4x: High Fidelity Model (2x/4x upscaling)\n            - sharpen_denoise: Sharp Denoise Model\n            - detail_denoise: Detail Denoise Model\n            - generative_portrait: Generative Portrait Model\n            - generative: Generative Enhance Model\n          enum:\n            - face_2x\n            - face_4x\n            - face_v2_2x\n            - face_v2_4x\n            - general_2x\n            - general_4x\n            - high_fidelity_2x\n            - high_fidelity_4x\n            - sharpen_denoise\n            - detail_denoise\n            - generative_portrait\n            - generative\n          example: \"generative_portrait\"\n        img_url:\n          type: string\n          format: uri\n          description: URL of the image to be enhanced. Must be publicly accessible.\n          example: \"https://example.com/image.jpg\"\n        extension:\n          type: string\n          description: File extension of the image (e.g., \".jpg\", \".png\")\n          example: \".jpg\"\n        exif:\n          type: boolean\n          description: Whether to preserve EXIF data (default false)\n          example: true\n        DPI:\n          type: integer\n          format: int64\n          description: Target DPI for the output image\n          example: 300\n    HitPawJobResponse:\n      type: object\n      description: Response from HitPaw Enhancement APIs (photo and video)\n      properties:\n        code:\n          type: integer\n          description: Status code, 200 indicates success\n          example: 200\n        message:\n          type: string\n          description: Response message\n          example: \"OK\"\n        data:\n          type: object\n          properties:\n            job_id:\n              type: string\n              description: Unique identifier for the enhancement job\n              example: \"f5007c0b-e902-4070-8c75-f337d896168f\"\n            consume_coins:\n              type: integer\n              description: Number of coins consumed for this task\n              example: 75\n    HitPawTaskStatusRequest:\n      type: object\n      description: Request body for HitPaw Task Status Query API\n      required:\n        - job_id\n      properties:\n        job_id:\n          type: string\n          description: Task ID obtained from Enhancement API response\n          example: \"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\"\n    HitPawTaskStatusResponse:\n      type: object\n      description: Response from HitPaw Task Status Query API\n      properties:\n        code:\n          type: integer\n          description: Status code, 200 indicates success\n          example: 200\n        message:\n          type: string\n          description: Response message\n          example: \"OK\"\n        data:\n          type: object\n          properties:\n            job_id:\n              type: string\n              description: Task ID\n              example: \"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\"\n            status:\n              type: string\n              description: |\n                Task status:\n                - WAITING: The job is queued and waiting to be processed\n                - CONVERTING: Processing task in progress\n                - COMPLETED: Task completed successfully\n                - ERROR: Task failed\n              enum: [\"WAITING\", \"CONVERTING\", \"COMPLETED\", \"ERROR\"]\n            res_url:\n              type: string\n              format: uri\n              description: Result URL, only valid when status is COMPLETED\n              example: \"https://example.com/result.jpg\"\n            original_url:\n              type: string\n              format: uri\n              description: Original Image URL (photo enhancement only)\n              example: \"https://example.com/original.jpg\"\n    HitPawErrorResponse:\n      type: object\n      description: Error response from HitPaw API\n      properties:\n        error_code:\n          type: integer\n          description: Error code\n        message:\n          type: string\n          description: Error message\n    HitPawVideoEnhancerRequest:\n      type: object\n      description: Request body for HitPaw Video Enhancement API\n      required:\n        - video_url\n        - model_name\n        - resolution\n      properties:\n        video_url:\n          type: string\n          format: uri\n          description: URL of the video to be enhanced\n          example: \"https://example.com/video.mp4\"\n        model_name:\n          type: string\n          description: |\n            Model name to use for enhancement.\n            \n            **Available Models:**\n            - face_soft: Face Soft Model\n            - portrait_restore_1x: Portrait Restore Model 1x\n            - portrait_restore_2x: Portrait Restore Model 2x\n            - general_restore_1x: General Restore Model 1x\n            - general_restore_2x: General Restore Model 2x\n            - general_restore_4x: General Restore Model 4x\n            - ultrahd_restore: Ultra HD Model\n            - generative: Generative Model (SD)\n          enum:\n            - face_soft\n            - portrait_restore_1x\n            - portrait_restore_2x\n            - general_restore_1x\n            - general_restore_2x\n            - general_restore_4x\n            - ultrahd_restore\n            - generative\n          example: \"general_restore_2x\"\n        resolution:\n          type: array\n          description: Target resolution [width, height]\n          items:\n            type: integer\n          minItems: 2\n          maxItems: 2\n          example: [1920, 1080]\n        extension:\n          type: string\n          description: File extension for the output video (default \".mp4\")\n          default: \".mp4\"\n          example: \".mp4\"\n        original_resolution:\n          type: array\n          description: Original video resolution [width, height]\n          items:\n            type: integer\n          minItems: 2\n          maxItems: 2\n          example: [1280, 720]\n\n    # ElevenLabs Schemas\n    ElevenLabsVoiceSettings:\n      type: object\n      nullable: true\n      description: Voice settings configuration\n      properties:\n        stability:\n          type: number\n          format: double\n          nullable: true\n          minimum: 0\n          maximum: 1\n          default: 0.5\n          description: Stability of the voice. Lower values introduce broader emotional range.\n        similarity_boost:\n          type: number\n          format: double\n          nullable: true\n          minimum: 0\n          maximum: 1\n          default: 0.75\n          description: How closely the AI adheres to the original voice when replicating it.\n        style:\n          type: number\n          format: double\n          nullable: true\n          minimum: 0\n          maximum: 1\n          default: 0\n          description: Style exaggeration. Amplifies the style of the original speaker.\n        use_speaker_boost:\n          type: boolean\n          nullable: true\n          default: true\n          description: Boosts similarity to the original speaker. Requires higher computational load.\n        speed:\n          type: number\n          format: double\n          nullable: true\n          minimum: 0.7\n          maximum: 1.2\n          default: 1.0\n          description: Speed adjustment. 1.0 is default, values below slow down, values above speed up.\n\n    ElevenLabsTTSRequest:\n      type: object\n      description: Request body for ElevenLabs Text to Speech\n      properties:\n        text:\n          type: string\n          description: The text that will be converted into speech.\n        model_id:\n          type: string\n          description: Identifier of the model to use. Query /v1/models to list available models.\n          default: eleven_multilingual_v2\n        language_code:\n          type: string\n          nullable: true\n          description: Language code (ISO 639-1) to enforce for the model. If unsupported, an error is returned.\n        voice_settings:\n          $ref: \"#/components/schemas/ElevenLabsVoiceSettings\"\n        pronunciation_dictionary_locators:\n          type: array\n          nullable: true\n          items:\n            $ref: \"#/components/schemas/ElevenLabsPronunciationDictionaryLocator\"\n          description: List of pronunciation dictionary locators (id, version_id). Maximum 3 per request.\n          maxItems: 3\n        seed:\n          type: integer\n          nullable: true\n          description: Seed for deterministic generation. Must be between 0 and 4294967295.\n          minimum: 0\n          maximum: 4294967295\n        previous_text:\n          type: string\n          nullable: true\n          description: Text that came before this request, used to improve speech continuity.\n        next_text:\n          type: string\n          nullable: true\n          description: Text that comes after this request, used to improve speech continuity.\n        previous_request_ids:\n          type: array\n          nullable: true\n          items:\n            type: string\n          description: Request IDs of previous generations for continuity. Maximum 3.\n          maxItems: 3\n        next_request_ids:\n          type: array\n          nullable: true\n          items:\n            type: string\n          description: Request IDs of next generations for continuity. Maximum 3.\n          maxItems: 3\n        apply_text_normalization:\n          type: string\n          enum:\n            - auto\n            - \"on\"\n            - \"off\"\n          default: auto\n          description: |\n            Controls text normalization. 'auto' lets the system decide, 'on' always applies normalization,\n            'off' skips normalization.\n        apply_language_text_normalization:\n          type: boolean\n          default: false\n          description: Controls language-specific text normalization. Can heavily increase latency. Currently only supported for Japanese.\n        use_pvc_as_ivc:\n          type: boolean\n          default: false\n          description: Deprecated. If true, uses IVC version of voice instead of PVC.\n      required:\n        - text\n\n    ElevenLabsPronunciationDictionaryLocator:\n      type: object\n      description: Locator for a pronunciation dictionary\n      properties:\n        pronunciation_dictionary_id:\n          type: string\n          description: The ID of the pronunciation dictionary\n        version_id:\n          type: string\n          description: The version ID of the pronunciation dictionary\n      required:\n        - pronunciation_dictionary_id\n        - version_id\n\n    ElevenLabsValidationError:\n      type: object\n      description: Validation error response from ElevenLabs\n      properties:\n        detail:\n          type: object\n          description: Details about the validation error\n          properties:\n            status:\n              type: string\n              description: Error status\n            message:\n              type: string\n              description: Error message\n\n    # ElevenLabs Speech-to-Text Schemas\n    ElevenLabsSTTRequest:\n      type: object\n      description: Request body for ElevenLabs Speech-to-Text\n      required:\n        - model_id\n      properties:\n        model_id:\n          type: string\n          enum:\n            - scribe_v1\n            - scribe_v2\n          description: The ID of the model to use for transcription.\n        file:\n          type: string\n          format: binary\n          description: |\n            The file to transcribe. All major audio and video formats are supported.\n            Exactly one of file or cloud_storage_url parameters must be provided.\n            The file size must be less than 3.0GB.\n        language_code:\n          type: string\n          nullable: true\n          description: |\n            An ISO-639-1 or ISO-639-3 language_code corresponding to the language of the audio file.\n            Can sometimes improve transcription performance if known beforehand.\n            Defaults to null, in this case the language is predicted automatically.\n        tag_audio_events:\n          type: boolean\n          default: true\n          description: Whether to tag audio events like (laughter), (footsteps), etc. in the transcription.\n        num_speakers:\n          type: integer\n          nullable: true\n          description: |\n            The maximum amount of speakers talking in the uploaded file.\n            Can help with predicting who speaks when. The maximum amount of speakers that can be predicted is 32.\n            Defaults to null, in this case the amount of speakers is set to the maximum value the model supports.\n        timestamps_granularity:\n          type: string\n          enum:\n            - none\n            - word\n            - character\n          default: word\n          description: |\n            The granularity of the timestamps in the transcription.\n            'word' provides word-level timestamps and 'character' provides character-level timestamps per word.\n        diarize:\n          type: boolean\n          default: false\n          description: Whether to annotate which speaker is currently talking in the uploaded file.\n        diarization_threshold:\n          type: number\n          format: double\n          nullable: true\n          description: |\n            Diarization threshold to apply during speaker diarization.\n            A higher value means there will be a lower chance of one speaker being diarized as two different speakers.\n            Can only be set when diarize=True and num_speakers=None. Defaults to None.\n        additional_formats:\n          type: array\n          nullable: true\n          items:\n            $ref: \"#/components/schemas/ElevenLabsSTTExportOptions\"\n          description: A list of additional formats to export the transcript to.\n        file_format:\n          type: string\n          enum:\n            - pcm_s16le_16\n            - other\n          default: other\n          description: |\n            The format of input audio. Options are 'pcm_s16le_16' or 'other'.\n            For pcm_s16le_16, the input audio must be 16-bit PCM at a 16kHz sample rate, single channel (mono).\n        cloud_storage_url:\n          type: string\n          nullable: true\n          description: |\n            The HTTPS URL of the file to transcribe. Exactly one of file or cloud_storage_url parameters must be provided.\n            The file must be accessible via HTTPS and the file size must be less than 2GB.\n        webhook:\n          type: boolean\n          default: false\n          description: |\n            Whether to send the transcription result to configured speech-to-text webhooks.\n            If set the request will return early without the transcription, which will be delivered later via webhook.\n        webhook_id:\n          type: string\n          nullable: true\n          description: |\n            Optional specific webhook ID to send the transcription result to.\n            Only valid when webhook is set to true.\n        temperature:\n          type: number\n          format: double\n          nullable: true\n          description: |\n            Controls the randomness of the transcription output. Accepts values between 0.0 and 2.0.\n            Higher values result in more diverse and less deterministic results.\n        seed:\n          type: integer\n          nullable: true\n          description: |\n            If specified, our system will make a best effort to sample deterministically.\n            Must be an integer between 0 and 2147483647.\n          minimum: 0\n          maximum: 2147483647\n        use_multi_channel:\n          type: boolean\n          default: false\n          description: |\n            Whether the audio file contains multiple channels where each channel contains a single speaker.\n            When enabled, each channel will be transcribed independently and the results will be combined.\n            A maximum of 5 channels is supported.\n        webhook_metadata:\n          type: string\n          nullable: true\n          description: |\n            Optional metadata to be included in the webhook response.\n            This should be a JSON string representing an object with a maximum depth of 2 levels and maximum size of 16KB.\n        entity_detection:\n          nullable: true\n          oneOf:\n            - type: string\n            - type: array\n              items:\n                type: string\n          description: |\n            Detect entities in the transcript. Can be 'all' to detect all entities,\n            a single entity type or category string, or a list of entity types/categories.\n            Categories include 'pii', 'phi', 'pci', 'other', 'offensive_language'.\n            When enabled, detected entities will be returned in the 'entities' field\n            with their text, type, and character positions. Usage of this parameter will incur additional costs.\n        keyterms:\n          type: array\n          nullable: true\n          items:\n            type: string\n          description: |\n            A list of keyterms to bias the transcription towards.\n            The number of keyterms cannot exceed 100 and each keyterm must be less than 50 characters.\n\n    ElevenLabsSTTExportOptions:\n      type: object\n      description: Export format options for speech-to-text transcripts\n      required:\n        - format\n      properties:\n        format:\n          type: string\n          enum:\n            - segmented_json\n            - docx\n            - pdf\n            - txt\n            - html\n            - srt\n          description: The output format for the transcript export.\n        include_speakers:\n          type: boolean\n          default: true\n          description: Whether to include speaker labels in the export.\n        include_timestamps:\n          type: boolean\n          default: true\n          description: Whether to include timestamps in the export.\n        segment_on_silence_longer_than_s:\n          type: number\n          format: double\n          nullable: true\n          description: Segment the transcript when silence is longer than this value in seconds.\n        max_segment_duration_s:\n          type: number\n          format: double\n          nullable: true\n          description: Maximum duration of each segment in seconds.\n        max_segment_chars:\n          type: integer\n          nullable: true\n          description: Maximum number of characters per segment.\n        max_characters_per_line:\n          type: integer\n          nullable: true\n          description: Maximum characters per line (for txt and srt formats).\n\n    ElevenLabsSTTResponse:\n      type: object\n      description: Response from ElevenLabs Speech-to-Text\n      properties:\n        language_code:\n          type: string\n          description: The detected language code (e.g. 'eng' for English).\n        language_probability:\n          type: number\n          format: double\n          description: The confidence score of the language detection (0 to 1).\n        text:\n          type: string\n          description: The raw text of the transcription.\n        words:\n          type: array\n          items:\n            $ref: \"#/components/schemas/ElevenLabsSTTWord\"\n          description: List of words with their timing information.\n        channel_index:\n          type: integer\n          nullable: true\n          description: The channel index this transcript belongs to (for multichannel audio).\n        additional_formats:\n          type: array\n          nullable: true\n          items:\n            $ref: \"#/components/schemas/ElevenLabsSTTAdditionalFormat\"\n          description: Requested additional formats of the transcript.\n        transcription_id:\n          type: string\n          nullable: true\n          description: The transcription ID of the response.\n        entities:\n          type: array\n          nullable: true\n          items:\n            $ref: \"#/components/schemas/ElevenLabsSTTDetectedEntity\"\n          description: List of detected entities with their text, type, and character positions.\n        transcripts:\n          type: array\n          nullable: true\n          items:\n            $ref: \"#/components/schemas/ElevenLabsSTTTranscript\"\n          description: List of transcripts for multichannel audio (when use_multi_channel is true).\n        message:\n          type: string\n          nullable: true\n          description: Message for webhook responses.\n        request_id:\n          type: string\n          nullable: true\n          description: Request ID for webhook responses.\n\n    ElevenLabsSTTWord:\n      type: object\n      description: Word information from speech-to-text transcription\n      required:\n        - text\n        - type\n        - logprob\n      properties:\n        text:\n          type: string\n          description: The word or sound that was transcribed.\n        start:\n          type: number\n          format: double\n          nullable: true\n          description: The start time of the word or sound in seconds.\n        end:\n          type: number\n          format: double\n          nullable: true\n          description: The end time of the word or sound in seconds.\n        type:\n          type: string\n          enum:\n            - word\n            - spacing\n            - audio_event\n          description: |\n            The type of the word or sound.\n            'audio_event' is used for non-word sounds like laughter or footsteps.\n        speaker_id:\n          type: string\n          nullable: true\n          description: Unique identifier for the speaker of this word.\n        logprob:\n          type: number\n          format: double\n          description: |\n            The log of the probability with which this word was predicted.\n            Logprobs are in range [-infinity, 0], higher logprobs indicate higher confidence.\n        characters:\n          type: array\n          nullable: true\n          items:\n            $ref: \"#/components/schemas/ElevenLabsSTTCharacter\"\n          description: The characters that make up the word and their timing information.\n\n    ElevenLabsSTTCharacter:\n      type: object\n      description: Character information with timing\n      required:\n        - text\n      properties:\n        text:\n          type: string\n          description: The character that was transcribed.\n        start:\n          type: number\n          format: double\n          nullable: true\n          description: The start time of the character in seconds.\n        end:\n          type: number\n          format: double\n          nullable: true\n          description: The end time of the character in seconds.\n\n    ElevenLabsSTTAdditionalFormat:\n      type: object\n      description: Additional format response for transcript export\n      required:\n        - requested_format\n        - file_extension\n        - content_type\n        - is_base64_encoded\n        - content\n      properties:\n        requested_format:\n          type: string\n          description: The requested format.\n        file_extension:\n          type: string\n          description: The file extension of the additional format.\n        content_type:\n          type: string\n          description: The content type of the additional format.\n        is_base64_encoded:\n          type: boolean\n          description: Whether the content is base64 encoded.\n        content:\n          type: string\n          description: The content of the additional format.\n\n    ElevenLabsSTTDetectedEntity:\n      type: object\n      description: Detected entity in transcript\n      required:\n        - text\n        - entity_type\n        - start_char\n        - end_char\n      properties:\n        text:\n          type: string\n          description: The text that was identified as an entity.\n        entity_type:\n          type: string\n          description: The type of entity detected (e.g., 'credit_card', 'email_address', 'person_name').\n        start_char:\n          type: integer\n          description: Start character position in the transcript text.\n        end_char:\n          type: integer\n          description: End character position in the transcript text.\n\n    ElevenLabsSTTTranscript:\n      type: object\n      description: Individual transcript for multichannel audio\n      properties:\n        language_code:\n          type: string\n          description: The detected language code.\n        language_probability:\n          type: number\n          format: double\n          description: The confidence score of the language detection.\n        text:\n          type: string\n          description: The raw text of the transcription.\n        words:\n          type: array\n          items:\n            $ref: \"#/components/schemas/ElevenLabsSTTWord\"\n          description: List of words with their timing information.\n        channel_index:\n          type: integer\n          nullable: true\n          description: The channel index this transcript belongs to.\n        additional_formats:\n          type: array\n          nullable: true\n          items:\n            $ref: \"#/components/schemas/ElevenLabsSTTAdditionalFormat\"\n          description: Requested additional formats.\n        entities:\n          type: array\n          nullable: true\n          items:\n            $ref: \"#/components/schemas/ElevenLabsSTTDetectedEntity\"\n          description: List of detected entities.\n\n    # ElevenLabs Speech-to-Speech (Voice Changer) Schemas\n    ElevenLabsSpeechToSpeechRequest:\n      type: object\n      description: Request body for ElevenLabs Speech-to-Speech (Voice Changer)\n      required:\n        - audio\n      properties:\n        audio:\n          type: string\n          format: binary\n          description: The audio file which holds the content and emotion that will control the generated speech.\n        model_id:\n          type: string\n          default: eleven_english_sts_v2\n          description: |\n            Identifier of the model that will be used. Query GET /v1/models to list available models.\n            The model needs to have support for speech to speech (can_do_voice_conversion property).\n        voice_settings:\n          type: string\n          nullable: true\n          description: |\n            Voice settings overriding stored settings for the given voice.\n            They are applied only on the given request. Needs to be sent as a JSON encoded string.\n        seed:\n          type: integer\n          nullable: true\n          description: |\n            If specified, our system will make a best effort to sample deterministically.\n            Repeated requests with the same seed and parameters should return the same result.\n            Must be integer between 0 and 4294967295.\n          minimum: 0\n          maximum: 4294967295\n        remove_background_noise:\n          type: boolean\n          default: false\n          description: |\n            If set, will remove the background noise from your audio input using our audio isolation model.\n            Only applies to Voice Changer.\n        file_format:\n          type: string\n          nullable: true\n          enum:\n            - pcm_s16le_16\n            - other\n          default: other\n          description: |\n            The format of input audio. Options are 'pcm_s16le_16' or 'other'.\n            For pcm_s16le_16, the input audio must be 16-bit PCM at a 16kHz sample rate, single channel (mono).\n\n    # ElevenLabs Text-to-Dialogue Schemas\n    ElevenLabsTextToDialogueRequest:\n      type: object\n      description: Request body for ElevenLabs Text-to-Dialogue (multi-voice TTS)\n      required:\n        - inputs\n      properties:\n        inputs:\n          type: array\n          items:\n            $ref: \"#/components/schemas/ElevenLabsDialogueInput\"\n          description: |\n            A list of dialogue inputs, each containing text and a voice ID which will be converted into speech.\n            The maximum number of unique voice IDs is 10.\n        model_id:\n          type: string\n          default: eleven_v3\n          description: |\n            Identifier of the model that will be used. Query GET /v1/models to list available models.\n            The model needs to have support for text to speech (can_do_text_to_speech property).\n        language_code:\n          type: string\n          nullable: true\n          description: |\n            Language code (ISO 639-1) used to enforce a language for the model and text normalization.\n            If the model does not support provided language code, an error will be returned.\n        settings:\n          $ref: \"#/components/schemas/ElevenLabsDialogueSettings\"\n        pronunciation_dictionary_locators:\n          type: array\n          nullable: true\n          items:\n            $ref: \"#/components/schemas/ElevenLabsPronunciationDictionaryLocator\"\n          description: |\n            A list of pronunciation dictionary locators (id, version_id) to be applied to the text.\n            They will be applied in order. You may have up to 3 locators per request.\n          maxItems: 3\n        seed:\n          type: integer\n          nullable: true\n          description: |\n            If specified, our system will make a best effort to sample deterministically.\n            Repeated requests with the same seed and parameters should return the same result.\n            Must be integer between 0 and 4294967295.\n          minimum: 0\n          maximum: 4294967295\n        apply_text_normalization:\n          type: string\n          enum:\n            - auto\n            - \"on\"\n            - \"off\"\n          default: auto\n          description: |\n            Controls text normalization with three modes:\n            'auto' - system automatically decides whether to apply text normalization\n            'on' - text normalization will always be applied\n            'off' - text normalization will be skipped\n\n    ElevenLabsDialogueInput:\n      type: object\n      description: A single dialogue input containing text and voice ID\n      required:\n        - text\n        - voice_id\n      properties:\n        text:\n          type: string\n          description: The text to be converted into speech.\n        voice_id:\n          type: string\n          description: The ID of the voice to be used for the generation.\n\n    ElevenLabsDialogueSettings:\n      type: object\n      nullable: true\n      description: Settings controlling the dialogue generation\n      properties:\n        stability:\n          type: number\n          format: double\n          nullable: true\n          default: 0.5\n          description: |\n            Determines how stable the voice is and the randomness between each generation.\n            Lower values introduce broader emotional range for the voice.\n            Higher values can result in a monotonous voice with limited emotion.\n\n    # ElevenLabs Audio Isolation Schemas\n    ElevenLabsAudioIsolationRequest:\n      type: object\n      description: Request body for audio isolation (removing background noise)\n      required:\n        - audio\n      properties:\n        audio:\n          type: string\n          format: binary\n          description: The audio file from which vocals/speech will be isolated.\n        file_format:\n          type: string\n          nullable: true\n          enum:\n            - pcm_s16le_16\n            - other\n          default: other\n          description: |\n            The format of input audio. Options are 'pcm_s16le_16' or 'other'.\n            For pcm_s16le_16, the input audio must be 16-bit PCM at a 16kHz sample rate, single channel (mono).\n            Latency will be lower than with passing an encoded waveform.\n        preview_b64:\n          type: string\n          nullable: true\n          description: Optional preview image base64 for tracking this generation.\n\n    ElevenLabsCreateVoiceRequest:\n      type: object\n      description: Request body for creating an instant voice clone\n      required:\n        - name\n        - files\n      properties:\n        name:\n          type: string\n          description: The name that identifies this voice.\n        files:\n          type: array\n          items:\n            type: string\n            format: binary\n          description: Audio recordings for voice cloning.\n        remove_background_noise:\n          type: boolean\n          default: false\n          description: If set, removes background noise from voice samples using audio isolation.\n        description:\n          type: string\n          nullable: true\n          description: A description of the voice.\n        labels:\n          type: string\n          nullable: true\n          description: JSON string of labels for the voice (language, accent, gender, age).\n\n    ElevenLabsSoundGenerationRequest:\n      type: object\n      description: Request body for generating sound effects from text\n      required:\n        - text\n      properties:\n        text:\n          type: string\n          description: The text that will get converted into a sound effect.\n        loop:\n          type: boolean\n          default: false\n          description: |\n            Whether to create a sound effect that loops smoothly.\n            Only available for the 'eleven_text_to_sound_v2' model.\n        duration_seconds:\n          type: number\n          format: double\n          nullable: true\n          description: |\n            The duration of the sound which will be generated in seconds.\n            Must be at least 0.5 and at most 30. If set to null, the optimal\n            duration will be guessed using the prompt. Defaults to null.\n        prompt_influence:\n          type: number\n          format: double\n          description: |\n            A higher prompt influence makes your generation follow the prompt\n            more closely while also making generations less variable.\n            Must be a value between 0 and 1. Defaults to 0.3.\n        model_id:\n          type: string\n          default: eleven_text_to_sound_v2\n          description: The model ID to use for the sound generation.\n\n    KlingErrorResponse:\n      type: object\n      properties:\n        code:\n          type: integer\n          description: |\n            - 1000: Authentication failed\n            - 1001: Authorization is empty\n            - 1002: Authorization is invalid\n            - 1003: Authorization is not yet valid\n            - 1004: Authorization has expired\n            - 1100: Account exception\n            - 1101: Account in arrears (postpaid scenario)\n            - 1102: Resource pack depleted or expired (prepaid scenario)\n            - 1103: Unauthorized access to requested resource\n            - 1200: Invalid request parameters\n            - 1201: Invalid parameters\n            - 1202: Invalid request method\n            - 1203: Requested resource does not exist\n            - 1300: Trigger platform strategy\n            - 1301: Trigger content security policy\n            - 1302: API request too frequent\n            - 1303: Concurrency/QPS exceeds limit\n            - 1304: Trigger IP whitelist policy\n            - 5000: Internal server error\n            - 5001: Service temporarily unavailable\n            - 5002: Server internal timeout\n        message:\n          type: string\n          description: Human-readable error message\n        request_id:\n          type: string\n          description: Request ID for tracking and troubleshooting\n      required:\n        - code\n        - message\n        - request_id\n\n    TripoTask:\n      type: object\n      properties:\n        task_id:\n          type: string\n        type:\n          type: string\n        status:\n          type: string\n          enum:\n            - queued\n            - running\n            - success\n            - failed\n            - cancelled\n            - unknown\n            - banned\n            - expired\n        input:\n          type: object\n        output:\n          type: object\n          properties:\n            model:\n              type: string\n            base_model:\n              type: string\n            pbr_model:\n              type: string\n            rendered_image:\n              type: string\n            riggable:\n              type: boolean\n            topology:\n              type: string\n              enum:\n                - \"bip\"\n                - \"quad\"\n        progress:\n          type: integer\n          minimum: 0\n          maximum: 100\n        create_time:\n          type: integer\n      required:\n        - task_id\n        - type\n        - status\n        - input\n        - output\n        - progress\n        - create_time\n    TripoSuccessTask:\n      type: object\n      properties:\n        code:\n          type: integer\n          enum:\n            - 0\n        data:\n          type: object\n          properties:\n            task_id:\n              description: used for getTask\n              type: string\n          required:\n            - task_id\n      required:\n        - code\n        - data\n    TripoBalance:\n      type: object\n      properties:\n        balance:\n          type: number\n        frozen:\n          type: number\n      required: [\"balance\", \"frozen\"]\n    TripoErrorResponse:\n      type: object\n      properties:\n        code:\n          type: integer\n          enum:\n            - 1001\n            - 2000\n            - 2001\n            - 2002\n            - 2003\n            - 2004\n            - 2006\n            - 2007\n            - 2008\n            - 2010\n        message:\n          type: string\n        suggestion:\n          type: string\n      required:\n        - code\n        - message\n        - suggestion\n    TripoResponseSuccessCode:\n      type: integer\n      description: \"Standard success code for Tripo API responses. Typically 0 for success.\"\n      example: 0\n    TripoTextToModel:\n      type: string\n      description: \"The type of the Tripo task, specifically for text-to-model operations.\"\n      enum:\n        - text_to_model\n      example: text_to_model\n    TripoModelVersion:\n      type: string\n      description: \"Version of the Tripo model.\"\n      enum:\n        - \"v2.5-20250123\"\n        - \"v2.0-20240919\"\n        - \"v1.4-20240625\"\n      example: \"v2.5-20250123\"\n    TripoModelStyle:\n      type: string\n      description: \"Style for the Tripo model generation.\"\n      enum:\n        - \"person:person2cartoon\"\n        - \"animal:venom\"\n        - \"object:clay\"\n        - \"object:steampunk\"\n        - \"object:christmas\"\n        - \"object:barbie\"\n        - \"gold\"\n        - \"ancient_bronze\"\n      example: \"object:clay\"\n    TripoImageToModel:\n      type: string\n      description: \"Task type for Tripo image-to-model generation.\"\n      enum:\n        - \"image_to_model\"\n      example: \"image_to_model\"\n    TripoMultiviewToModel:\n      type: string\n      description: \"Task type for Tripo multiview-to-model generation.\"\n      enum:\n        - \"multiview_to_model\"\n      example: \"multiview_to_model\"\n    TripoMultiviewMode:\n      type: string\n      description: \"Mode for multiview generation, specifying view orientation.\"\n      enum:\n        - LEFT\n        - RIGHT\n      example: LEFT\n    TripoTextureQuality:\n      type: string\n      enum:\n        - standard\n        - detailed\n    TripoTextureAlignment:\n      type: string\n      enum:\n        - original_image\n        - geometry\n    TripoOrientation:\n      type: string\n      enum:\n        - align_image\n        - default\n      default: default\n    TripoTypeTextureModel:\n      type: string\n      enum:\n        - texture_model\n    TripoTypeRefineModel:\n      type: string\n      enum:\n        - refine_model\n    TripoTypeAnimatePrerigcheck:\n      type: string\n      enum:\n        - animate_prerigcheck\n    TripoTypeAnimateRig:\n      type: string\n      enum:\n        - animate_rig\n    TripoStandardFormat:\n      type: string\n      enum:\n        - glb\n        - fbx\n    TripoTopology:\n      type: string\n      enum:\n        - \"bip\"\n        - \"quad\"\n    TripoSpec:\n      type: string\n      enum:\n        - \"mixamo\"\n        - \"tripo\"\n    TripoTypeAnimateRetarget:\n      type: string\n      enum:\n        - animate_retarget\n    TripoAnimation:\n      type: string\n      enum:\n        - preset:idle\n        - preset:walk\n        - preset:climb\n        - preset:jump\n        - preset:run\n        - preset:slash\n        - preset:shoot\n        - preset:hurt\n        - preset:fall\n        - preset:turn\n    TripoTypeStylizeModel:\n      type: string\n      enum:\n        - stylize_model\n    TripoStylizeOptions:\n      type: string\n      enum:\n        - lego\n        - voxel\n        - voronoi\n        - minecraft\n    TripoTypeConvertModel:\n      type: string\n      enum:\n        - convert_model\n    TripoConvertFormat:\n      type: string\n      enum:\n        - GLTF\n        - USDZ\n        - FBX\n        - OBJ\n        - STL\n        - 3MF\n    TripoTextureFormat:\n      type: string\n      enum:\n        - BMP\n        - DPX\n        - HDR\n        - JPEG\n        - OPEN_EXR\n        - PNG\n        - TARGA\n        - TIFF\n        - WEBP\n    TripoGeometryQuality:\n      type: string\n      enum:\n        - standard\n        - detailed\n    LumaAspectRatio:\n      type: string\n      enum:\n        - \"1:1\"\n        - \"16:9\"\n        - \"9:16\"\n        - \"4:3\"\n        - \"3:4\"\n        - \"21:9\"\n        - \"9:21\"\n      description: The aspect ratio of the generation\n      example: \"16:9\"\n      default: \"16:9\"\n    LumaKeyframes:\n      type: object\n      description: The keyframes of the generation\n      properties:\n        frame0:\n          $ref: \"#/components/schemas/LumaKeyframe\"\n        frame1:\n          $ref: \"#/components/schemas/LumaKeyframe\"\n      example:\n        frame0:\n          type: image\n          url: \"https://example.com/image.jpg\"\n        frame1:\n          type: generation\n          id: \"123e4567-e89b-12d3-a456-426614174000\"\n    LumaVideoModel:\n      type: string\n      enum:\n        - ray-2\n        - ray-flash-2\n        - ray-1-6\n      default: ray-2\n      example: ray-2\n      description: The video model used for the generation\n    LumaVideoModelOutputResolution:\n      anyOf:\n        - type: string\n          enum:\n            - 540p\n            - 720p\n            - 1080p\n            - 4k\n        - type: string\n    LumaVideoModelOutputDuration:\n      anyOf:\n        - type: string\n          enum:\n            - 5s\n            - 9s\n        - type: string\n    LumaImageModel:\n      type: string\n      enum:\n        - photon-1\n        - photon-flash-1\n      default: photon-1\n      description: The image model used for the generation\n    LumaImageRef:\n      type: object\n      description: The image reference object\n      properties:\n        url:\n          type: string\n          format: uri\n          description: The URL of the image reference\n        weight:\n          type: number\n          description: The weight of the image reference\n    LumaImageIdentity:\n      type: object\n      description: The image identity object\n      properties:\n        images:\n          type: array\n          items:\n            type: string\n            format: uri\n          description: The URLs of the image identity\n    LumaModifyImageRef:\n      type: object\n      description: The modify image reference object\n      properties:\n        url:\n          type: string\n          format: uri\n          description: The URL of the image reference\n        weight:\n          type: number\n          description: The weight of the modify image reference\n    LumaGenerationReference:\n      type: object\n      description: The generation reference object\n      properties:\n        type:\n          type: string\n          enum:\n            - generation\n          default: generation\n        id:\n          type: string\n          format: uuid\n          description: The ID of the generation\n      required:\n        - type\n        - id\n      example:\n        type: generation\n        id: \"123e4567-e89b-12d3-a456-426614174003\"\n    LumaImageReference:\n      type: object\n      description: The image object\n      properties:\n        type:\n          type: string\n          enum:\n            - image\n          default: image\n        url:\n          type: string\n          format: uri\n          description: The URL of the image\n      required:\n        - type\n        - url\n      example:\n        type: image\n        url: \"https://example.com/image.jpg\"\n    LumaKeyframe:\n      oneOf:\n        - $ref: \"#/components/schemas/LumaGenerationReference\"\n        - $ref: \"#/components/schemas/LumaImageReference\"\n      discriminator:\n        propertyName: type\n        mapping:\n          generation: \"#/components/schemas/LumaGenerationReference\"\n          image: \"#/components/schemas/LumaImageReference\"\n      description: A keyframe can be either a Generation reference, an Image, or a Video\n    LumaGenerationType:\n      type: string\n      enum:\n        - video\n        - image\n    LumaState:\n      type: string\n      description: The state of the generation\n      enum:\n        - queued\n        - dreaming\n        - completed\n        - failed\n      example: completed\n    LumaAssets:\n      type: object\n      description: The assets of the generation\n      properties:\n        video:\n          type: string\n          format: uri\n          description: The URL of the video\n        image:\n          type: string\n          format: uri\n          description: The URL of the image\n        progress_video:\n          type: string\n          format: uri\n          description: The URL of the progress video\n    LumaGenerationRequest:\n      type: object\n      description: The generation request object\n      properties:\n        generation_type:\n          type: string\n          enum:\n            - video\n          default: video\n        prompt:\n          type: string\n          description: The prompt of the generation\n        aspect_ratio:\n          $ref: \"#/components/schemas/LumaAspectRatio\"\n        loop:\n          type: boolean\n          description: Whether to loop the video\n        keyframes:\n          $ref: \"#/components/schemas/LumaKeyframes\"\n        callback_url:\n          type: string\n          format: uri\n          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\n        model:\n          $ref: \"#/components/schemas/LumaVideoModel\"\n        resolution:\n          $ref: \"#/components/schemas/LumaVideoModelOutputResolution\"\n        duration:\n          $ref: \"#/components/schemas/LumaVideoModelOutputDuration\"\n      required:\n        - duration\n        - resolution\n        - prompt\n        - aspect_ratio\n        - model\n    LumaImageGenerationRequest:\n      type: object\n      description: The image generation request object\n      properties:\n        generation_type:\n          type: string\n          enum:\n            - image\n          default: image\n        model:\n          $ref: \"#/components/schemas/LumaImageModel\"\n        prompt:\n          type: string\n          description: The prompt of the generation\n        aspect_ratio:\n          $ref: \"#/components/schemas/LumaAspectRatio\"\n        callback_url:\n          type: string\n          format: uri\n          description: The callback URL for the generation\n        image_ref:\n          type: array\n          items:\n            $ref: \"#/components/schemas/LumaImageRef\"\n        style_ref:\n          type: array\n          items:\n            $ref: \"#/components/schemas/LumaImageRef\"\n        character_ref:\n          type: object\n          properties:\n            identity0:\n              $ref: \"#/components/schemas/LumaImageIdentity\"\n        modify_image_ref:\n          $ref: \"#/components/schemas/LumaModifyImageRef\"\n    LumaUpscaleVideoGenerationRequest:\n      type: object\n      description: The upscale generation request object\n      properties:\n        generation_type:\n          type: string\n          enum:\n            - upscale_video\n          default: upscale_video\n        resolution:\n          $ref: \"#/components/schemas/LumaVideoModelOutputResolution\"\n        callback_url:\n          type: string\n          format: uri\n          description: The callback URL for the upscale\n    LumaAudioGenerationRequest:\n      type: object\n      description: The audio generation request object\n      properties:\n        generation_type:\n          type: string\n          enum:\n            - add_audio\n          default: add_audio\n        prompt:\n          type: string\n          description: The prompt of the audio\n        negative_prompt:\n          type: string\n          description: The negative prompt of the audio\n        callback_url:\n          type: string\n          format: uri\n          description: The callback URL for the audio\n    LumaError:\n      type: object\n      description: The error object\n      properties:\n        detail:\n          type: string\n          description: The error message\n      example:\n        detail: \"Invalid API key is provided\"\n    LumaGeneration:\n      type: object\n      description: The generation response object\n      properties:\n        id:\n          type: string\n          format: uuid\n          description: The ID of the generation\n        generation_type:\n          $ref: \"#/components/schemas/LumaGenerationType\"\n        state:\n          $ref: \"#/components/schemas/LumaState\"\n        failure_reason:\n          type: string\n          description: The reason for the state of the generation\n        created_at:\n          type: string\n          format: date-time\n          description: The date and time when the generation was created\n        assets:\n          $ref: \"#/components/schemas/LumaAssets\"\n        model:\n          type: string\n          description: The model used for the generation\n        request:\n          oneOf:\n            - $ref: \"#/components/schemas/LumaGenerationRequest\"\n            - $ref: \"#/components/schemas/LumaImageGenerationRequest\"\n            - $ref: \"#/components/schemas/LumaUpscaleVideoGenerationRequest\"\n            - $ref: \"#/components/schemas/LumaAudioGenerationRequest\"\n          description: The request of the generation\n      example:\n        id: \"123e4567-e89b-12d3-a456-426614174000\"\n        state: \"completed\"\n        failure_reason: null\n        created_at: \"2023-06-01T12:00:00Z\"\n        assets:\n          video: \"https://example.com/video.mp4\"\n        model: \"ray-2\"\n        request:\n          prompt: \"A serene lake surrounded by mountains at sunset\"\n          aspect_ratio: \"16:9\"\n          loop: true\n          keyframes:\n            frame0:\n              type: image\n              url: \"https://example.com/image.jpg\"\n            frame1:\n              type: generation\n              id: \"123e4567-e89b-12d3-a456-426614174000\"\n\n    PixverseTextVideoRequest:\n      type: object\n      required:\n        - aspect_ratio\n        - duration\n        - model\n        - prompt\n        - quality\n      properties:\n        aspect_ratio:\n          type: string\n          enum: [\"16:9\", \"4:3\", \"1:1\", \"3:4\", \"9:16\"]\n        duration:\n          type: integer\n          enum: [5, 8]\n        model:\n          type: string\n          enum: [v3.5]\n        motion_mode:\n          type: string\n          enum: [normal, fast]\n        negative_prompt:\n          type: string\n        prompt:\n          type: string\n        quality:\n          type: string\n          enum: [360p, 540p, 720p, 1080p]\n        seed:\n          type: integer\n        style:\n          type: string\n          enum: [anime, 3d_animation, clay, comic, cyberpunk]\n        template_id:\n          type: integer\n        water_mark:\n          type: boolean\n    PixverseVideoResponse:\n      type: object\n      properties:\n        ErrCode:\n          type: integer\n        ErrMsg:\n          type: string\n        Resp:\n          type: object\n          properties:\n            video_id:\n              type: integer\n    PixverseImageUploadResponse:\n      type: object\n      properties:\n        ErrCode:\n          type: integer\n        ErrMsg:\n          type: string\n        Resp:\n          type: object\n          properties:\n            img_id:\n              type: integer\n    PixverseImageVideoRequest:\n      type: object\n      required:\n        - img_id\n        - model\n        - duration\n        - quality\n        - prompt\n      properties:\n        img_id:\n          type: integer\n        model:\n          type: string\n          enum: [v3.5]\n        prompt:\n          type: string\n        duration:\n          type: integer\n          enum: [5, 8]\n        quality:\n          type: string\n          enum: [360p, 540p, 720p, 1080p]\n        motion_mode:\n          type: string\n          enum: [normal, fast]\n        seed:\n          type: integer\n        style:\n          type: string\n          enum: [anime, 3d_animation, clay, comic, cyberpunk]\n        template_id:\n          type: integer\n        water_mark:\n          type: boolean\n    PixverseTransitionVideoRequest:\n      type: object\n      required:\n        - first_frame_img\n        - last_frame_img\n        - model\n        - duration\n        - quality\n        - prompt\n        - motion_mode\n        - seed\n      properties:\n        first_frame_img:\n          type: integer\n        last_frame_img:\n          type: integer\n        model:\n          type: string\n          enum: [v3.5]\n        duration:\n          type: integer\n          enum: [5, 8]\n        quality:\n          type: string\n          enum: [360p, 540p, 720p, 1080p]\n        motion_mode:\n          type: string\n          enum: [normal, fast]\n        seed:\n          type: integer\n        prompt:\n          type: string\n        style:\n          type: string\n          enum: [anime, 3d_animation, clay, comic, cyberpunk]\n        template_id:\n          type: integer\n        water_mark:\n          type: boolean\n    PixverseVideoResultResponse:\n      type: object\n      properties:\n        ErrCode:\n          type: integer\n        ErrMsg:\n          type: string\n        Resp:\n          type: object\n          properties:\n            create_time:\n              type: string\n            id:\n              type: integer\n            modify_time:\n              type: string\n            negative_prompt:\n              type: string\n            outputHeight:\n              type: integer\n            outputWidth:\n              type: integer\n            prompt:\n              type: string\n            resolution_ratio:\n              type: integer\n            seed:\n              type: integer\n            size:\n              type: integer\n            status:\n              type: integer\n              enum: [1, 5, 6, 7, 8]\n              description: |\n                Video generation status codes:\n                * 1 - Generation successful\n                * 5 - Generating\n                * 6 - Deleted\n                * 7 - Contents moderation failed\n                * 8 - Generation failed\n            style:\n              type: string\n            url:\n              type: string\n    Veo2GenVidRequest:\n      type: object\n      properties:\n        instances:\n          type: array\n          items:\n            type: object\n            properties:\n              prompt:\n                type: string\n                description: Text description of the video\n              image:\n                type: object\n                description: Optional image to guide video generation\n                properties:\n                  bytesBase64Encoded:\n                    type: string\n                    format: byte\n                  gcsUri:\n                    type: string\n                  mimeType:\n                    type: string\n                oneOf:\n                  - required: [bytesBase64Encoded]\n                  - required: [gcsUri]\n            required:\n              - prompt\n        parameters:\n          type: object\n          properties:\n            aspectRatio:\n              type: string\n              example: \"16:9\"\n            negativePrompt:\n              type: string\n            personGeneration:\n              type: string\n              enum: [\"ALLOW\", \"BLOCK\"]\n            sampleCount:\n              type: integer\n            seed:\n              type: integer\n              format: uint32\n            storageUri:\n              type: string\n              description: Optional Cloud Storage URI to upload the video\n            durationSeconds:\n              type: integer\n            enhancePrompt:\n              type: boolean\n    Veo2GenVidResponse:\n      type: object\n      properties:\n        name:\n          type: string\n          description: Operation resource name\n          example: projects/PROJECT_ID/locations/us-central1/publishers/google/models/MODEL_ID/operations/a1b07c8e-7b5a-4aba-bb34-3e1ccb8afcc8\n      required:\n        - name\n    Veo2GenVidPollRequest:\n      type: object\n      properties:\n        operationName:\n          type: string\n          description: Full operation name (from predict response)\n          example: projects/PROJECT_ID/locations/us-central1/publishers/google/models/MODEL_ID/operations/OPERATION_ID\n      required:\n        - operationName\n    Veo2GenVidPollResponse:\n      type: object\n      properties:\n        name:\n          type: string\n        done:\n          type: boolean\n        response:\n          type: object\n          properties:\n            \"@type\":\n              type: string\n              example: type.googleapis.com/cloud.ai.large_models.vision.GenerateVideoResponse\n            raiMediaFilteredCount:\n              type: integer\n              description: Count of media filtered by responsible AI policies\n            raiMediaFilteredReasons:\n              type: array\n              items:\n                type: string\n              description: Reasons why media was filtered by responsible AI policies\n            videos:\n              type: array\n              items:\n                type: object\n                properties:\n                  gcsUri:\n                    type: string\n                    description: Cloud Storage URI of the video\n                  bytesBase64Encoded:\n                    type: string\n                    description: Base64-encoded video content\n                  mimeType:\n                    type: string\n                    description: Video MIME type\n          description: The actual prediction response if done is true\n        error:\n          type: object\n          description: Error details if operation failed\n          properties:\n            code:\n              type: integer\n              description: Error code\n            message:\n              type: string\n              description: Error message\n\n    VeoGenVidRequest:\n      type: object\n      properties:\n        instances:\n          type: array\n          items:\n            type: object\n            properties:\n              prompt:\n                type: string\n                description: Text description of the video\n              image:\n                type: object\n                description: Optional image to guide video generation\n                properties:\n                  bytesBase64Encoded:\n                    type: string\n                    format: byte\n                  gcsUri:\n                    type: string\n                  mimeType:\n                    type: string\n                oneOf:\n                  - required: [bytesBase64Encoded]\n                  - required: [gcsUri]\n              lastFrame:\n                type: object\n                description: Optional last frame image to guide video generation\n                properties:\n                  bytesBase64Encoded:\n                    type: string\n                    format: byte\n                  gcsUri:\n                    type: string\n                  mimeType:\n                    type: string\n                oneOf:\n                  - required: [bytesBase64Encoded]\n                  - required: [gcsUri]\n            required:\n              - prompt\n        parameters:\n          type: object\n          properties:\n            aspectRatio:\n              type: string\n              example: \"16:9\"\n            negativePrompt:\n              type: string\n            personGeneration:\n              type: string\n              enum: [\"ALLOW\", \"BLOCK\"]\n            sampleCount:\n              type: integer\n            seed:\n              type: integer\n              format: uint32\n            storageUri:\n              type: string\n              description: Optional Cloud Storage URI to upload the video\n            durationSeconds:\n              type: integer\n            enhancePrompt:\n              type: boolean\n            generateAudio:\n              type: boolean\n              description: Generate audio for the video. Only supported by veo 3 models.\n    VeoGenVidResponse:\n      type: object\n      properties:\n        name:\n          type: string\n          description: Operation resource name\n          example: projects/PROJECT_ID/locations/us-central1/publishers/google/models/MODEL_ID/operations/a1b07c8e-7b5a-4aba-bb34-3e1ccb8afcc8\n      required:\n        - name\n    VeoGenVidPollRequest:\n      type: object\n      properties:\n        operationName:\n          type: string\n          description: Full operation name (from predict response)\n          example: projects/PROJECT_ID/locations/us-central1/publishers/google/models/MODEL_ID/operations/OPERATION_ID\n      required:\n        - operationName\n    VeoGenVidPollResponse:\n      type: object\n      properties:\n        name:\n          type: string\n        done:\n          type: boolean\n        response:\n          type: object\n          properties:\n            \"@type\":\n              type: string\n              example: type.googleapis.com/cloud.ai.large_models.vision.GenerateVideoResponse\n            raiMediaFilteredCount:\n              type: integer\n              description: Count of media filtered by responsible AI policies\n            raiMediaFilteredReasons:\n              type: array\n              items:\n                type: string\n              description: Reasons why media was filtered by responsible AI policies\n            videos:\n              type: array\n              items:\n                type: object\n                properties:\n                  gcsUri:\n                    type: string\n                    description: Cloud Storage URI of the video\n                  bytesBase64Encoded:\n                    type: string\n                    description: Base64-encoded video content\n                  mimeType:\n                    type: string\n                    description: Video MIME type\n          description: The actual prediction response if done is true\n        error:\n          type: object\n          description: Error details if operation failed\n          properties:\n            code:\n              type: integer\n              description: Error code\n            message:\n              type: string\n              description: Error message\n\n    RunwayImageToVideoRequest:\n      type: object\n      properties:\n        promptImage:\n          $ref: \"#/components/schemas/RunwayPromptImageObject\"\n        seed:\n          type: integer\n          format: int64\n          minimum: 0\n          maximum: 4294967295\n          description: Random seed for generation\n        model:\n          $ref: \"#/components/schemas/RunwayModelEnum\"\n          description: Model to use for generation\n        promptText:\n          type: string\n          maxLength: 1000\n          description: Text prompt for the generation\n        duration:\n          $ref: \"#/components/schemas/RunwayDurationEnum\"\n          description: The number of seconds of duration for the output video.\n        ratio:\n          $ref: \"#/components/schemas/RunwayAspectRatioEnum\"\n          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.\n      required:\n        - promptImage\n        - seed\n        - model\n        - duration\n        - ratio\n    RunwayImageToVideoResponse:\n      type: object\n      properties:\n        id:\n          type: string\n          description: Task ID\n    RunwayTextToImageResponse:\n      type: object\n      properties:\n        id:\n          type: string\n          description: Task ID\n    RunwayTaskStatusResponse:\n      type: object\n      properties:\n        id:\n          type: string\n          description: Task ID\n        status:\n          $ref: \"#/components/schemas/RunwayTaskStatusEnum\"\n          description: Task status\n        createdAt:\n          type: string\n          format: date-time\n          description: Task creation timestamp\n        output:\n          type: array\n          items:\n            type: string\n          description: Array of output video URLs\n        progress:\n          type: number\n          format: float\n          minimum: 0\n          maximum: 1\n          description: Float value between 0 and 1 representing the progress of the task. Only available if status is RUNNING.\n      required:\n        - id\n        - status\n        - createdAt\n    RunwayTaskStatusEnum:\n      type: string\n      description: Possible statuses for a Runway task.\n      enum:\n        - SUCCEEDED\n        - RUNNING\n        - FAILED\n        - PENDING\n        - CANCELLED\n        - THROTTLED\n    RunwayModelEnum:\n      type: string\n      description: Available Runway models for generation.\n      enum:\n        - gen4_turbo\n        - gen3a_turbo\n    RunwayPromptImageDetailedObject:\n      type: object\n      description: Represents an image with its position in the video sequence.\n      properties:\n        uri:\n          type: string\n          description: A HTTPS URL or data URI containing an encoded image.\n        position:\n          type: string\n          description: The position of the image in the output video. 'last' is currently supported for gen3a_turbo only.\n          enum: [first, last]\n      required:\n        - uri\n        - position\n    RunwayDurationEnum:\n      type: integer\n      enum:\n        - 5\n        - 10\n    RunwayAspectRatioEnum:\n      type: string\n      enum:\n        - \"1280:720\"\n        - \"720:1280\"\n        - \"1104:832\"\n        - \"832:1104\"\n        - \"960:960\"\n        - \"1584:672\"\n        - \"1280:768\" # gen3a_turbo only\n        - \"768:1280\" # gen3a_turbo only\n    RunwayTextToImageAspectRatioEnum:\n      type: string\n      enum:\n        - \"1920:1080\"\n        - \"1080:1920\"\n        - \"1024:1024\"\n        - \"1360:768\"\n        - \"1080:1080\"\n        - \"1168:880\"\n        - \"1440:1080\"\n        - \"1080:1440\"\n        - \"1808:768\"\n        - \"2112:912\"\n    RunwayPromptImageObject:\n      oneOf:\n        - type: string\n          description: A single HTTPS URL or data URI for the first frame image.\n        - type: array\n          description: An array of image objects with positions. No two images can have the same position.\n          items:\n            $ref: \"#/components/schemas/RunwayPromptImageDetailedObject\"\n      description: Image(s) to use for the video generation. Can be a single URI or an array of image objects with positions.\n    OpenAIImageGenerationResponse:\n      type: object\n      properties:\n        data:\n          type: array\n          items:\n            type: object\n            properties:\n              b64_json:\n                type: string\n                description: Base64 encoded image data\n              url:\n                type: string\n                description: URL of the image\n              revised_prompt:\n                type: string\n                description: Revised prompt\n        usage:\n          type: object\n          properties:\n            input_tokens:\n              type: integer\n            input_tokens_details:\n              type: object\n              properties:\n                text_tokens:\n                  type: integer\n                image_tokens:\n                  type: integer\n            output_tokens:\n              type: integer\n            output_tokens_details:\n              type: object\n              properties:\n                text_tokens:\n                  type: integer\n                image_tokens:\n                  type: integer\n            total_tokens:\n              type: integer\n    OpenAIImageGenerationRequest:\n      type: object\n      required:\n        - prompt\n      properties:\n        model:\n          type: string\n          description: The model to use for image generation\n          example: \"dall-e-3\"\n        prompt:\n          type: string\n          description: A text description of the desired image\n          example: \"Draw a rocket in front of a blackhole in deep space\"\n        n:\n          type: integer\n          description: The number of images to generate (1-10). Only 1 supported for dall-e-3.\n          example: 1\n        quality:\n          type: string\n          description: The quality of the generated image\n          enum: [low, medium, high, standard, hd]\n          example: \"high\"\n        size:\n          type: string\n          description: Size of the image (e.g., 1024x1024, 1536x1024, auto)\n          example: \"1024x1536\"\n        output_format:\n          type: string\n          description: Format of the output image\n          enum: [png, webp, jpeg]\n          example: \"png\"\n        output_compression:\n          type: integer\n          description: Compression level for JPEG or WebP (0-100)\n          example: 100\n        moderation:\n          type: string\n          description: Content moderation setting\n          enum: [low, auto]\n          example: \"auto\"\n        background:\n          type: string\n          description: Background transparency\n          enum: [transparent, opaque]\n          example: \"opaque\"\n        response_format:\n          type: string\n          description: Response format of image data\n          enum: [url, b64_json]\n          example: \"b64_json\"\n        style:\n          type: string\n          description: Style of the image (only for dall-e-3)\n          enum: [vivid, natural]\n          example: \"vivid\"\n        user:\n          type: string\n          description: A unique identifier for end-user monitoring\n          example: \"user-1234\"\n    OpenAIImageEditRequest:\n      type: object\n      required:\n        - model\n        - prompt\n      properties:\n        model:\n          type: string\n          description: The model to use for image editing\n          example: \"gpt-image-1\"\n        prompt:\n          type: string\n          description: A text description of the desired edit\n          example: \"Give the rocketship rainbow coloring\"\n        n:\n          type: integer\n          description: The number of images to generate\n          example: 1\n        quality:\n          type: string\n          description: The quality of the edited image\n          example: \"low\"\n        size:\n          type: string\n          description: Size of the output image\n          example: \"1024x1024\"\n        output_format:\n          type: string\n          description: Format of the output image\n          enum: [png, webp, jpeg]\n          example: \"png\"\n        output_compression:\n          type: integer\n          description: Compression level for JPEG or WebP (0-100)\n          example: 100\n        moderation:\n          type: string\n          description: Content moderation setting\n          enum: [low, auto]\n          example: \"auto\"\n        background:\n          type: string\n          description: Background transparency\n          example: \"opaque\"\n        user:\n          type: string\n          description: A unique identifier for end-user monitoring\n          example: \"user-1234\"\n    OpenAIVideoCreateRequest:\n      type: object\n      required:\n        - prompt\n      properties:\n        prompt:\n          type: string\n          description: Text prompt that describes the video to generate\n          example: \"A calico cat playing a piano on stage\"\n        input_reference:\n          type: string\n          format: binary\n          description: Optional image or video reference that guides generation\n        model:\n          type: string\n          description: The video generation model to use\n          enum: [\"sora-2\", \"sora-2-pro\"]\n          default: \"sora-2\"\n        seconds:\n          type: string\n          description: Clip duration in seconds\n          enum: [\"4\", \"8\", \"12\"]\n          default: \"4\"\n        size:\n          type: string\n          description: Output resolution formatted as width x height\n          enum: [\"720x1280\", \"1280x720\", \"1024x1792\", \"1792x1024\"]\n          default: \"720x1280\"\n    OpenAIVideoJob:\n      type: object\n      properties:\n        id:\n          type: string\n          description: Unique identifier for the video job\n          example: \"video_123\"\n        object:\n          type: string\n          description: The object type, which is always video\n          enum: [video]\n          example: \"video\"\n        model:\n          type: string\n          description: The video generation model that produced the job\n          example: \"sora-2\"\n        status:\n          type: string\n          description: Current lifecycle status of the video job\n          enum: [queued, in_progress, completed, failed]\n          example: \"queued\"\n        progress:\n          type: integer\n          description: Approximate completion percentage for the generation task\n          example: 0\n        created_at:\n          type: integer\n          description: Unix timestamp (seconds) for when the job was created\n          example: 1712697600\n        completed_at:\n          type: integer\n          description: Unix timestamp (seconds) for when the job completed, if finished\n          example: 1712698600\n        expires_at:\n          type: integer\n          description: Unix timestamp (seconds) for when the downloadable assets expire, if set\n          example: 1712784000\n        size:\n          type: string\n          description: The resolution of the generated video\n          example: \"1024x1808\"\n        seconds:\n          type: string\n          description: Duration of the generated clip in seconds\n          example: \"8\"\n        quality:\n          type: string\n          description: Quality of the generated video\n          example: \"standard\"\n        remixed_from_video_id:\n          type: string\n          description: Identifier of the source video if this video is a remix\n          example: \"video_456\"\n        error:\n          type: object\n          description: Error payload that explains why generation failed, if applicable\n          properties:\n            code:\n              type: string\n              description: Error code\n            message:\n              type: string\n              description: Human-readable error message\n    CustomerStorageResourceResponse:\n      type: object\n      properties:\n        download_url:\n          type: string\n          description: The signed URL to use for downloading the file from the specified path\n        upload_url:\n          type: string\n          description: The signed URL to use for uploading the file to the specified path\n        expires_at:\n          type: string\n          format: date-time\n          description: When the signed URL will expire\n        existing_file:\n          type: boolean\n          description: Whether an existing file with the same hash was found\n    Pikaffect:\n      type: string\n      enum:\n        - Cake-ify\n        - Crumble\n        - Crush\n        - Decapitate\n        - Deflate\n        - Dissolve\n        - Explode\n        - Eye-pop\n        - Inflate\n        - Levitate\n        - Melt\n        - Peel\n        - Poke\n        - Squish\n        - Ta-da\n        - Tear\n    PikaBody_generate_pikaffects_generate_pikaffects_post:\n      properties:\n        image:\n          type: string\n          format: binary\n          title: Image\n        pikaffect:\n          $ref: \"#/components/schemas/Pikaffect\"\n          title: Pikaffect\n        promptText:\n          anyOf:\n            - type: string\n          title: Prompttext\n        negativePrompt:\n          anyOf:\n            - type: string\n          title: Negativeprompt\n        seed:\n          anyOf:\n            - type: integer\n          title: Seed\n      type: object\n      # required: TODO: this should be required, but need to make optional to pass validation\n      #     - image\n      title: Body_generate_pikaffects_generate_pikaffects_post\n    PikaGenerateResponse:\n      properties:\n        video_id:\n          type: string\n          title: Video Id\n      type: object\n      required:\n        - video_id\n      title: GenerateResponse\n    PikaHTTPValidationError:\n      properties:\n        detail:\n          items:\n            $ref: \"#/components/schemas/PikaValidationError\"\n          type: array\n          title: Detail\n      type: object\n      title: HTTPValidationError\n    PikaBody_generate_pikadditions_generate_pikadditions_post:\n      properties:\n        video:\n          type: string\n          format: binary\n          title: Video\n        image:\n          type: string\n          format: binary\n          title: Image\n        promptText:\n          anyOf:\n            - type: string\n          title: Prompttext\n        negativePrompt:\n          anyOf:\n            - type: string\n          title: Negativeprompt\n        seed:\n          anyOf:\n            - type: integer\n          title: Seed\n      type: object\n      # required:\n      # TODO: this should be required, but need to make optional to pass validation\n      # - video\n      # - image\n      title: Body_generate_pikadditions_generate_pikadditions_post\n    PikaBody_generate_pikaswaps_generate_pikaswaps_post:\n      properties:\n        video:\n          type: string\n          format: binary\n          title: Video\n        image:\n          anyOf:\n            - type: string\n              format: binary\n          title: Image\n        promptText:\n          anyOf:\n            - type: string\n          title: Prompttext\n        modifyRegionMask:\n          anyOf:\n            - type: string\n              format: binary\n          title: Modifyregionmask\n          description: >-\n            A mask image that specifies the region to modify, where the mask is white and the background is black\n        modifyRegionRoi:\n          anyOf:\n            - type: string\n          title: Modifyregionroi\n          description: Plaintext description of the object / region to modify\n        negativePrompt:\n          anyOf:\n            - type: string\n          title: Negativeprompt\n        seed:\n          anyOf:\n            - type: integer\n          title: Seed\n      type: object\n      # required: # TODO: this should be required, but need to make optional to pass validation\n      #     - video\n      title: Body_generate_pikaswaps_generate_pikaswaps_post\n    PikaBody_generate_2_2_t2v_generate_2_2_t2v_post:\n      properties:\n        promptText:\n          type: string\n          title: Prompttext\n        negativePrompt:\n          type: string\n          nullable: true\n          title: Negativeprompt\n        seed:\n          type: integer\n          nullable: true\n          title: Seed\n        resolution:\n          $ref: \"#/components/schemas/PikaResolutionEnum\"\n          title: Resolution\n        duration:\n          $ref: \"#/components/schemas/PikaDurationEnum\"\n          title: Duration\n        aspectRatio:\n          type: number\n          maximum: 2.5\n          minimum: 0.4\n          default: 1.7777777777777777\n          format: float\n          title: Aspectratio\n          description: Aspect ratio (width / height)\n      type: object\n      required:\n        - promptText\n      title: Body_generate_2_2_t2v_generate_2_2_t2v_post\n    PikaBody_generate_2_2_i2v_generate_2_2_i2v_post:\n      properties:\n        image:\n          type: string\n          format: binary\n          nullable: true # TODO: fix, this is not actually nullable, but needed to pass validation as it is not included in request body\n          title: Image\n        promptText:\n          type: string\n          nullable: true\n          title: Prompttext\n        negativePrompt:\n          type: string\n          nullable: true\n          title: Negativeprompt\n        seed:\n          type: integer\n          nullable: true\n          title: Seed\n        resolution:\n          title: Resolution\n          $ref: \"#/components/schemas/PikaResolutionEnum\"\n        duration:\n          $ref: \"#/components/schemas/PikaDurationEnum\"\n          title: Duration\n      type: object\n      # required: TODO: this should be required, but need to make optional to pass validation\n      #   - image\n      title: Body_generate_2_2_i2v_generate_2_2_i2v_post\n    PikaBody_generate_2_2_c2v_generate_2_2_pikascenes_post:\n      properties:\n        images:\n          items:\n            type: string\n            format: binary\n          type: array\n          title: Images\n        ingredientsMode:\n          type: string\n          enum:\n            - creative\n            - precise\n          title: Ingredientsmode\n        promptText:\n          anyOf:\n            - type: string\n          title: Prompttext\n        negativePrompt:\n          anyOf:\n            - type: string\n          title: Negativeprompt\n        seed:\n          anyOf:\n            - type: integer\n          title: Seed\n        resolution:\n          type: string\n          title: Resolution\n          default: 1080p\n        duration:\n          type: integer\n          title: Duration\n          default: 5\n        aspectRatio:\n          anyOf:\n            - type: number\n              maximum: 2.5\n              minimum: 0.4\n          title: Aspectratio\n          description: Aspect ratio (width / height)\n      type: object\n      required:\n        # - images # TODO: this should be required, but need to make optional to pass validation\n        - ingredientsMode\n      title: Body_generate_2_2_c2v_generate_2_2_pikascenes_post\n    PikaBody_generate_2_2_keyframe_generate_2_2_pikaframes_post:\n      properties:\n        keyFrames:\n          items:\n            type: string\n            format: binary\n          type: array\n          title: Keyframes\n          description: Array of keyframe images\n        promptText:\n          type: string\n          title: Prompttext\n        negativePrompt:\n          anyOf:\n            - type: string\n          title: Negativeprompt\n        seed:\n          anyOf:\n            - type: integer\n          title: Seed\n        resolution:\n          $ref: \"#/components/schemas/PikaResolutionEnum\"\n          title: Resolution\n        duration:\n          type: integer\n          minimum: 5\n          maximum: 10\n          title: Duration\n      type: object\n      required:\n        # - keyFrames # TODO: this should be required, but need to make optional to pass validation\n        - promptText\n      title: Body_generate_2_2_keyframe_generate_2_2_pikaframes_post\n    PikaVideoResponse:\n      properties:\n        id:\n          type: string\n          title: Id\n        status:\n          title: Status\n          description: The status of the video\n          $ref: \"#/components/schemas/PikaStatusEnum\"\n        url:\n          type: string\n          nullable: true\n          title: Url\n          default: null\n        progress:\n          type: integer\n          nullable: true\n          title: Progress\n          default: null\n      type: object\n      required:\n        - id\n        - status\n      title: VideoResponse\n    PikaStatusEnum:\n      type: string\n      enum:\n        - queued\n        - started\n        - finished\n    PikaValidationError:\n      properties:\n        loc:\n          items:\n            anyOf:\n              - type: string\n              - type: integer\n          type: array\n          title: Location\n        msg:\n          type: string\n          title: Message\n        type:\n          type: string\n          title: Error Type\n      type: object\n      required:\n        - loc\n        - msg\n        - type\n      title: ValidationError\n    PikaResolutionEnum:\n      type: string\n      enum:\n        - 1080p\n        - 720p\n      default: 1080p\n    PikaDurationEnum:\n      type: integer\n      enum:\n        - 5\n        - 10\n      default: 5\n\n    RGBColor:\n      type: object\n      description: RGB color values\n      properties:\n        rgb:\n          type: array\n          items:\n            type: integer\n            minimum: 0\n            maximum: 255\n          minItems: 3\n          maxItems: 3\n      required:\n        - rgb\n      example:\n        rgb: [255, 0, 0]\n    StabilityError:\n      type: object\n      properties:\n        id:\n          type: string\n          minLength: 1\n          description: >\n            A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n            you file, as it will greatly assist us in diagnosing the root cause of the problem.\n          example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n        name:\n          type: string\n          minLength: 1\n          description: Short-hand name for an error, useful for discriminating between errors with the same status code.\n          example: bad_request\n        errors:\n          type: array\n          items:\n            type: string\n          minItems: 1\n          description: One or more error messages indicating what went wrong.\n          example:\n            - some-field: is required\n      required:\n        - id\n        - name\n        - errors\n      example:\n        id: 2a1b2d4eafe2bc6ab4cd4d5c6133f513\n        name: internal_error\n        errors:\n          - An unexpected server error has occurred, please try again later.\n    StabilityStabilityClientID:\n      type: string\n      maxLength: 256\n      description: >-\n        The name of your application, used to help us communicate app-specific debugging or moderation issues to you.\n      example: my-awesome-app\n    StabilityStabilityClientUserID:\n      type: string\n      maxLength: 256\n      description: >-\n        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.\n      example: \"DiscordUser#9999\"\n    StabilityStabilityClientVersion:\n      type: string\n      maxLength: 256\n      description: >-\n        The version of your application, used to help us communicate version-specific debugging or moderation issues to you.\n      example: 1.2.1\n    StabilityContentModerationResponse:\n      type: object\n      properties:\n        id:\n          type: string\n          minLength: 1\n          description: >-\n            A unique identifier associated with this error. Please include this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n            you file, as it will greatly assist us in diagnosing the root cause of the problem.\n          example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n        name:\n          type: string\n          minLength: 1\n          description: >-\n            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.\n            If you would like to provide feedback, please use the [Support Form](https://kb.stability.ai/knowledge-base/kb-tickets/new).\n          enum:\n            - content_moderation\n        errors:\n          type: array\n          items:\n            type: string\n          minItems: 1\n          description: One or more error messages indicating what went wrong.\n          example:\n            - \"some-field: is required\"\n      required:\n        - id\n        - name\n        - errors\n      description: Your request was flagged by our content moderation system.\n      example:\n        id: ed14db44362126aab3cbd25cca51ffe3\n        name: content_moderation\n        errors:\n          - >-\n            Your request was flagged by our content moderation system, as a result your request was denied and you were not charged.\n    ImagenGenerateImageRequest:\n      type: object\n      properties:\n        instances:\n          type: array\n          items:\n            $ref: \"#/components/schemas/ImagenImageGenerationInstance\"\n        parameters:\n          $ref: \"#/components/schemas/ImagenImageGenerationParameters\"\n      required:\n        - instances\n        - parameters\n    ImagenGenerateImageResponse:\n      type: object\n      properties:\n        predictions:\n          type: array\n          items:\n            $ref: \"#/components/schemas/ImagenImagePrediction\"\n    ImagenImageGenerationInstance:\n      type: object\n      properties:\n        prompt:\n          type: string\n          description: Text prompt for image generation\n      required:\n        - prompt\n    ImagenImageGenerationParameters:\n      type: object\n      properties:\n        sampleCount:\n          type: integer\n          minimum: 1\n          maximum: 4\n        seed:\n          type: integer\n          format: uint32\n        addWatermark:\n          type: boolean\n        aspectRatio:\n          type: string\n          enum: [\"1:1\", \"9:16\", \"16:9\", \"3:4\", \"4:3\"]\n        enhancePrompt:\n          type: boolean\n        includeRaiReason:\n          type: boolean\n        includeSafetyAttributes:\n          type: boolean\n        outputOptions:\n          $ref: \"#/components/schemas/ImagenOutputOptions\"\n        personGeneration:\n          type: string\n          enum: [\"dont_allow\", \"allow_adult\", \"allow_all\"]\n        safetySetting:\n          type: string\n          enum: [\"block_most\", \"block_some\", \"block_few\", \"block_fewest\"]\n        storageUri:\n          type: string\n          format: uri\n    ImagenImagePrediction:\n      type: object\n      properties:\n        mimeType:\n          type: string\n          description: MIME type of the generated image\n        prompt:\n          type: string\n          description: Enhanced or rewritten prompt used to generate this image\n        bytesBase64Encoded:\n          type: string\n          format: byte\n          description: Base64-encoded image content\n    ImagenOutputOptions:\n      type: object\n      properties:\n        mimeType:\n          type: string\n          enum: [\"image/png\", \"image/jpeg\"]\n        compressionQuality:\n          type: integer\n          minimum: 0\n          maximum: 100\n\n    RenderingSpeed:\n      type: string\n      description: The rendering speed setting that controls the trade-off between generation speed and quality\n      enum:\n        - DEFAULT\n        - TURBO\n        - QUALITY\n      default: DEFAULT\n    IdeogramStyleType:\n      type: string\n      enum: [\"AUTO\", \"GENERAL\", \"REALISTIC\", \"DESIGN\", \"FICTION\"]\n      default: \"GENERAL\"\n\n    StabilityCreativity:\n      type: number\n      minimum: 0.2\n      maximum: 0.5\n      default: 0.35\n      description:\n        Controls the likelihood of creating additional details not heavily\n        conditioned by the init image.\n    StabilityGenerationID:\n      type: string\n      minLength: 64\n      maxLength: 64\n      description:\n        The `id` of a generation, typically used for async generations,\n        that can be used to check the status of the generation or retrieve the result.\n      example: a6dc6c6e20acda010fe14d71f180658f2896ed9b4ec25aa99a6ff06c796987c4\n    StabilityImageGenerationSD3_Request:\n      type: object\n      properties:\n        prompt:\n          type: string\n          minLength: 1\n          maxLength: 10000\n          description:\n            \"What you wish to see in the output image. A strong, descriptive\n            prompt that clearly defines\n\n            elements, colors, and subjects will lead to better results.\"\n        mode:\n          type: string\n          enum:\n            - text-to-image\n            - image-to-image\n          default: text-to-image\n          description:\n            \"Controls whether this is a text-to-image or image-to-image\n            generation, which affects which parameters are required:\n\n            - **text-to-image** requires only the `prompt` parameter\n\n            - **image-to-image** requires the `prompt`, `image`, and `strength` parameters\"\n          title: GenerationMode\n        image:\n          type: string\n          description:\n            \"The image to use as the starting point for the generation.\\n\\\n            \\nSupported formats:\\n\\n\\n\\n  - jpeg\\n  - png\\n  - webp\\n\\nSupported dimensions:\\n\\\n            \\n\\n\\n  - Every side must be at least 64 pixels\\n\\n> **Important:** This\\\n            \\ parameter is only valid for **image-to-image** requests.\"\n          format: binary\n        strength:\n          type: number\n          minimum: 0\n          maximum: 1\n          description:\n            \"Sometimes referred to as _denoising_, this parameter controls\n            how much influence the\n\n            `image` parameter has on the generated image.  A value of 0 would yield\n            an image that\n\n            is identical to the input.  A value of 1 would be as if you passed in\n            no image at all.\n\n\n            > **Important:** This parameter is only valid for **image-to-image** requests.\"\n        aspect_ratio:\n          type: string\n          enum:\n            - \"21:9\"\n            - \"16:9\"\n            - \"3:2\"\n            - \"5:4\"\n            - \"1:1\"\n            - \"4:5\"\n            - \"2:3\"\n            - \"9:16\"\n            - \"9:21\"\n          default: \"1:1\"\n          description:\n            \"Controls the aspect ratio of the generated image. Defaults\n            to 1:1.\n\n\n            > **Important:** This parameter is only valid for **text-to-image** requests.\"\n        model:\n          type: string\n          enum:\n            - sd3.5-large\n            - sd3.5-large-turbo\n            - sd3.5-medium\n          default: sd3.5-large\n          description:\n            \"The model to use for generation.\\n\\n- `sd3.5-large` requires\\\n            \\ 6.5 credits per generation\\n- `sd3.5-large-turbo` requires 4 credits\\\n            \\ per generation\\n- `sd3.5-medium` requires 3.5 credits per generation\\n\\\n            - As of the April 17, 2025, `sd3-large`, `sd3-large-turbo` and `sd3-medium`\\n\\\n            \\n\\n\\n  are re-routed to their `sd3.5-[model version]` equivalent, at\\\n            \\ the same price.\"\n        seed:\n          type: number\n          minimum: 0\n          maximum: 4294967294\n          default: 0\n          description:\n            A specific value that is used to guide the 'randomness' of\n            the generation. (Omit this parameter or pass `0` to use a random seed.)\n        output_format:\n          type: string\n          enum:\n            - png\n            - jpeg\n          default: png\n          description: Dictates the `content-type` of the generated image.\n        style_preset:\n          type: string\n          enum:\n            - enhance\n            - anime\n            - photographic\n            - digital-art\n            - comic-book\n            - fantasy-art\n            - line-art\n            - analog-film\n            - neon-punk\n            - isometric\n            - low-poly\n            - origami\n            - modeling-compound\n            - cinematic\n            - 3d-model\n            - pixel-art\n            - tile-texture\n          description: Guides the image model towards a particular style.\n        negative_prompt:\n          type: string\n          maxLength: 10000\n          description:\n            \"Keywords of what you **do not** wish to see in the output\n            image.\n\n            This is an advanced feature.\"\n        cfg_scale:\n          type: number\n          minimum: 1\n          maximum: 10\n          description:\n            How strictly the diffusion process adheres to the prompt text\n            (higher values keep your image closer to your prompt). The _Large_ and\n            _Medium_ models use a default of `4`. The _Turbo_ model uses a default\n            of `1`.\n      required:\n        - prompt\n    StabilityImageGenrationSD3_Response_200:\n      type: object\n      properties:\n        image:\n          type: string\n          description: The generated image, encoded to base64.\n          example: AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1...\n        seed:\n          type: number\n          minimum: 0\n          maximum: 4294967294\n          default: 0\n          description: The seed used as random noise for this generation.\n          example: 343940597\n        finish_reason:\n          type: string\n          enum:\n            - SUCCESS\n            - CONTENT_FILTERED\n          description: \"The reason the generation finished.\n\n\n            - `SUCCESS` = successful generation.\n\n            - `CONTENT_FILTERED` = successful generation, however the output violated\n            our content moderation\n\n            policy and has been blurred as a result.\"\n          example: SUCCESS\n      required:\n        - image\n        - finish_reason\n    StabilityImageGenrationSD3_Response_400:\n      type: object\n      properties:\n        id:\n          type: string\n          minLength: 1\n          description:\n            \"A unique identifier associated with this error. Please include\n            this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n\n            you file, as it will greatly assist us in diagnosing the root cause of\n            the problem.\"\n          example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n        name:\n          type: string\n          minLength: 1\n          description:\n            Short-hand name for an error, useful for discriminating between\n            errors with the same status code.\n          example: bad_request\n        errors:\n          type: array\n          items:\n            type: string\n          minItems: 1\n          description: One or more error messages indicating what went wrong.\n          example:\n            - \"some-field: is required\"\n      required:\n        - id\n        - name\n        - errors\n    StabilityImageGenrationSD3_Response_413:\n      type: object\n      properties:\n        id:\n          type: string\n          minLength: 1\n          description:\n            \"A unique identifier associated with this error. Please include\n            this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n\n            you file, as it will greatly assist us in diagnosing the root cause of\n            the problem.\"\n          example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n        name:\n          type: string\n          minLength: 1\n          description:\n            Short-hand name for an error, useful for discriminating between\n            errors with the same status code.\n          example: bad_request\n        errors:\n          type: array\n          items:\n            type: string\n          minItems: 1\n          description: One or more error messages indicating what went wrong.\n          example:\n            - \"some-field: is required\"\n      required:\n        - id\n        - name\n        - errors\n      example:\n        id: 4212a4b66fbe1cedca4bf2133d35dca5\n        name: payload_too_large\n        errors:\n          - \"body: payloads cannot be larger than 10MiB in size\"\n    StabilityImageGenrationSD3_Response_422:\n      type: object\n      properties:\n        id:\n          type: string\n          minLength: 1\n          description:\n            \"A unique identifier associated with this error. Please include\n            this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n\n            you file, as it will greatly assist us in diagnosing the root cause of\n            the problem.\"\n          example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n        name:\n          type: string\n          minLength: 1\n          description:\n            Short-hand name for an error, useful for discriminating between\n            errors with the same status code.\n          example: bad_request\n        errors:\n          type: array\n          items:\n            type: string\n          minItems: 1\n          description: One or more error messages indicating what went wrong.\n          example:\n            - \"some-field: is required\"\n      required:\n        - id\n        - name\n        - errors\n    StabilityImageGenrationSD3_Response_429:\n      type: object\n      properties:\n        id:\n          type: string\n          minLength: 1\n          description:\n            \"A unique identifier associated with this error. Please include\n            this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n\n            you file, as it will greatly assist us in diagnosing the root cause of\n            the problem.\"\n          example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n        name:\n          type: string\n          minLength: 1\n          description:\n            Short-hand name for an error, useful for discriminating between\n            errors with the same status code.\n          example: bad_request\n        errors:\n          type: array\n          items:\n            type: string\n          minItems: 1\n          description: One or more error messages indicating what went wrong.\n          example:\n            - \"some-field: is required\"\n      required:\n        - id\n        - name\n        - errors\n      example:\n        id: rate_limit_exceeded\n        name: rate_limit_exceeded\n        errors:\n          - You have exceeded the rate limit of 150 requests within a 10 second period,\n            and have been timed out for 60 seconds.\n    StabilityImageGenrationSD3_Response_500:\n      type: object\n      properties:\n        id:\n          type: string\n          minLength: 1\n          description:\n            \"A unique identifier associated with this error. Please include\n            this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n\n            you file, as it will greatly assist us in diagnosing the root cause of\n            the problem.\"\n          example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n        name:\n          type: string\n          minLength: 1\n          description:\n            Short-hand name for an error, useful for discriminating between\n            errors with the same status code.\n          example: bad_request\n        errors:\n          type: array\n          items:\n            type: string\n          minItems: 1\n          description: One or more error messages indicating what went wrong.\n          example:\n            - \"some-field: is required\"\n      required:\n        - id\n        - name\n        - errors\n      example:\n        id: 2a1b2d4eafe2bc6ab4cd4d5c6133f513\n        name: internal_error\n        errors:\n          - An unexpected server error has occurred, please try again later.\n    StabilityImageGenrationUpscaleConservative_Request:\n      type: object\n      properties:\n        image:\n          type: string\n          description: \"The image you wish to upscale.\n\n\n            Supported Formats:\n\n            - jpeg\n\n            - png\n\n            - webp\n\n\n            Validation Rules:\n\n            - Every side must be at least 64 pixels\n\n            - Total pixel count must be between 4,096 and 9,437,184 pixels\n\n            - The aspect ratio must be between 1:2.5 and 2.5:1\"\n          format: binary\n          example: ./some/image.png\n        prompt:\n          type: string\n          minLength: 1\n          maxLength: 10000\n          description:\n            \"What you wish to see in the output image. A strong, descriptive\n            prompt that clearly defines\n\n            elements, colors, and subjects will lead to better results.\n\n\n            To control the weight of a given word use the format `(word:weight)`,\n\n            where `word` is the word you'd like to control the weight of and `weight`\n\n            is a value between 0 and 1. For example: `The sky was a crisp (blue:0.3)\n            and (green:0.8)`\n\n            would convey a sky that was blue and green, but more green than blue.\"\n        negative_prompt:\n          type: string\n          maxLength: 10000\n          description:\n            \"A blurb of text describing what you **do not** wish to see\n            in the output image.\n\n            This is an advanced feature.\"\n        seed:\n          type: number\n          minimum: 0\n          maximum: 4294967294\n          default: 0\n          description:\n            A specific value that is used to guide the 'randomness' of\n            the generation. (Omit this parameter or pass `0` to use a random seed.)\n        output_format:\n          type: string\n          enum:\n            - jpeg\n            - png\n            - webp\n          default: png\n          description: Dictates the `content-type` of the generated image.\n        creativity:\n          $ref: \"#/components/schemas/StabilityCreativity\"\n      required:\n        - image\n        - prompt\n    StabilityImageGenrationUpscaleConservative_Response_200:\n      type: object\n      properties:\n        image:\n          type: string\n          description: The generated image, encoded to base64.\n          example: AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1...\n        seed:\n          type: number\n          minimum: 0\n          maximum: 4294967294\n          default: 0\n          description: The seed used as random noise for this generation.\n          example: 343940597\n        finish_reason:\n          type: string\n          enum:\n            - SUCCESS\n            - CONTENT_FILTERED\n          description: \"The reason the generation finished.\n\n\n            - `SUCCESS` = successful generation.\n\n            - `CONTENT_FILTERED` = successful generation, however the output violated\n            our content moderation\n\n            policy and has been blurred as a result.\"\n          example: SUCCESS\n      required:\n        - image\n        - finish_reason\n    StabilityImageGenrationUpscaleConservative_Response_400:\n      type: object\n      properties:\n        id:\n          type: string\n          minLength: 1\n          description:\n            \"A unique identifier associated with this error. Please include\n            this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n\n            you file, as it will greatly assist us in diagnosing the root cause of\n            the problem.\"\n          example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n        name:\n          type: string\n          minLength: 1\n          description:\n            Short-hand name for an error, useful for discriminating between\n            errors with the same status code.\n          example: bad_request\n        errors:\n          type: array\n          items:\n            type: string\n          minItems: 1\n          description: One or more error messages indicating what went wrong.\n          example:\n            - \"some-field: is required\"\n      required:\n        - id\n        - name\n        - errors\n    StabilityImageGenrationUpscaleConservative_Response_413:\n      type: object\n      properties:\n        id:\n          type: string\n          minLength: 1\n          description:\n            \"A unique identifier associated with this error. Please include\n            this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n\n            you file, as it will greatly assist us in diagnosing the root cause of\n            the problem.\"\n          example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n        name:\n          type: string\n          minLength: 1\n          description:\n            Short-hand name for an error, useful for discriminating between\n            errors with the same status code.\n          example: bad_request\n        errors:\n          type: array\n          items:\n            type: string\n          minItems: 1\n          description: One or more error messages indicating what went wrong.\n          example:\n            - \"some-field: is required\"\n      required:\n        - id\n        - name\n        - errors\n      example:\n        id: 4212a4b66fbe1cedca4bf2133d35dca5\n        name: payload_too_large\n        errors:\n          - \"body: payloads cannot be larger than 10MiB in size\"\n    StabilityImageGenrationUpscaleConservative_Response_422:\n      type: object\n      properties:\n        id:\n          type: string\n          minLength: 1\n          description:\n            \"A unique identifier associated with this error. Please include\n            this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n\n            you file, as it will greatly assist us in diagnosing the root cause of\n            the problem.\"\n          example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n        name:\n          type: string\n          minLength: 1\n          description:\n            Short-hand name for an error, useful for discriminating between\n            errors with the same status code.\n          example: bad_request\n        errors:\n          type: array\n          items:\n            type: string\n          minItems: 1\n          description: One or more error messages indicating what went wrong.\n          example:\n            - \"some-field: is required\"\n      required:\n        - id\n        - name\n        - errors\n    StabilityImageGenrationUpscaleConservative_Response_429:\n      type: object\n      properties:\n        id:\n          type: string\n          minLength: 1\n          description:\n            \"A unique identifier associated with this error. Please include\n            this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n\n            you file, as it will greatly assist us in diagnosing the root cause of\n            the problem.\"\n          example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n        name:\n          type: string\n          minLength: 1\n          description:\n            Short-hand name for an error, useful for discriminating between\n            errors with the same status code.\n          example: bad_request\n        errors:\n          type: array\n          items:\n            type: string\n          minItems: 1\n          description: One or more error messages indicating what went wrong.\n          example:\n            - \"some-field: is required\"\n      required:\n        - id\n        - name\n        - errors\n      example:\n        id: rate_limit_exceeded\n        name: rate_limit_exceeded\n        errors:\n          - You have exceeded the rate limit of 150 requests within a 10 second period,\n            and have been timed out for 60 seconds.\n    StabilityImageGenrationUpscaleConservative_Response_500:\n      type: object\n      properties:\n        id:\n          type: string\n          minLength: 1\n          description:\n            \"A unique identifier associated with this error. Please include\n            this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n\n            you file, as it will greatly assist us in diagnosing the root cause of\n            the problem.\"\n          example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n        name:\n          type: string\n          minLength: 1\n          description:\n            Short-hand name for an error, useful for discriminating between\n            errors with the same status code.\n          example: bad_request\n        errors:\n          type: array\n          items:\n            type: string\n          minItems: 1\n          description: One or more error messages indicating what went wrong.\n          example:\n            - \"some-field: is required\"\n      required:\n        - id\n        - name\n        - errors\n      example:\n        id: 2a1b2d4eafe2bc6ab4cd4d5c6133f513\n        name: internal_error\n        errors:\n          - An unexpected server error has occurred, please try again later.\n    StabilityImageGenrationUpscaleCreative_Request:\n      type: object\n      properties:\n        image:\n          type: string\n          description: \"The image you wish to upscale.\n\n\n            Supported Formats:\n\n            - jpeg\n\n            - png\n\n            - webp\n\n\n            Validation Rules:\n\n            - Every side must be at least 64 pixels\n\n            - Total pixel count must be between 4,096 and 1,048,576 pixels\"\n          format: binary\n          example: ./some/image.png\n        prompt:\n          type: string\n          minLength: 1\n          maxLength: 10000\n          description:\n            \"What you wish to see in the output image. A strong, descriptive\n            prompt that clearly defines\n\n            elements, colors, and subjects will lead to better results.\n\n\n            To control the weight of a given word use the format `(word:weight)`,\n\n            where `word` is the word you'd like to control the weight of and `weight`\n\n            is a value between 0 and 1. For example: `The sky was a crisp (blue:0.3)\n            and (green:0.8)`\n\n            would convey a sky that was blue and green, but more green than blue.\"\n        negative_prompt:\n          type: string\n          maxLength: 10000\n          description:\n            \"A blurb of text describing what you **do not** wish to see\n            in the output image.\n\n            This is an advanced feature.\"\n        output_format:\n          type: string\n          enum:\n            - jpeg\n            - png\n            - webp\n          default: png\n          description: Dictates the `content-type` of the generated image.\n        seed:\n          type: number\n          minimum: 0\n          maximum: 4294967294\n          default: 0\n          description:\n            A specific value that is used to guide the 'randomness' of\n            the generation. (Omit this parameter or pass `0` to use a random seed.)\n        creativity:\n          type: number\n          minimum: 0.1\n          maximum: 0.5\n          default: 0.3\n          description:\n            \"Indicates how creative the model should be when upscaling\n            an image.\n\n            Higher values will result in more details being added to the image during\n            upscaling.\"\n        style_preset:\n          type: string\n          enum:\n            - enhance\n            - anime\n            - photographic\n            - digital-art\n            - comic-book\n            - fantasy-art\n            - line-art\n            - analog-film\n            - neon-punk\n            - isometric\n            - low-poly\n            - origami\n            - modeling-compound\n            - cinematic\n            - 3d-model\n            - pixel-art\n            - tile-texture\n          description: Guides the image model towards a particular style.\n      required:\n        - image\n        - prompt\n    StabilityImageGenrationUpscaleCreative_Response_200:\n      type: object\n      properties:\n        id:\n          $ref: \"#/components/schemas/StabilityGenerationID\"\n      required:\n        - id\n    StabilityImageGenrationUpscaleCreative_Response_400:\n      type: object\n      properties:\n        id:\n          type: string\n          minLength: 1\n          description:\n            \"A unique identifier associated with this error. Please include\n            this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n\n            you file, as it will greatly assist us in diagnosing the root cause of\n            the problem.\"\n          example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n        name:\n          type: string\n          minLength: 1\n          description:\n            Short-hand name for an error, useful for discriminating between\n            errors with the same status code.\n          example: bad_request\n        errors:\n          type: array\n          items:\n            type: string\n          minItems: 1\n          description: One or more error messages indicating what went wrong.\n          example:\n            - \"some-field: is required\"\n      required:\n        - id\n        - name\n        - errors\n    StabilityImageGenrationUpscaleCreative_Response_413:\n      type: object\n      properties:\n        id:\n          type: string\n          minLength: 1\n          description:\n            \"A unique identifier associated with this error. Please include\n            this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n\n            you file, as it will greatly assist us in diagnosing the root cause of\n            the problem.\"\n          example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n        name:\n          type: string\n          minLength: 1\n          description:\n            Short-hand name for an error, useful for discriminating between\n            errors with the same status code.\n          example: bad_request\n        errors:\n          type: array\n          items:\n            type: string\n          minItems: 1\n          description: One or more error messages indicating what went wrong.\n          example:\n            - \"some-field: is required\"\n      required:\n        - id\n        - name\n        - errors\n      example:\n        id: 4212a4b66fbe1cedca4bf2133d35dca5\n        name: payload_too_large\n        errors:\n          - \"body: payloads cannot be larger than 10MiB in size\"\n    StabilityImageGenrationUpscaleCreative_Response_422:\n      type: object\n      properties:\n        id:\n          type: string\n          minLength: 1\n          description:\n            \"A unique identifier associated with this error. Please include\n            this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n\n            you file, as it will greatly assist us in diagnosing the root cause of\n            the problem.\"\n          example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n        name:\n          type: string\n          minLength: 1\n          description:\n            Short-hand name for an error, useful for discriminating between\n            errors with the same status code.\n          example: bad_request\n        errors:\n          type: array\n          items:\n            type: string\n          minItems: 1\n          description: One or more error messages indicating what went wrong.\n          example:\n            - \"some-field: is required\"\n      required:\n        - id\n        - name\n        - errors\n    StabilityImageGenrationUpscaleCreative_Response_429:\n      type: object\n      properties:\n        id:\n          type: string\n          minLength: 1\n          description:\n            \"A unique identifier associated with this error. Please include\n            this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n\n            you file, as it will greatly assist us in diagnosing the root cause of\n            the problem.\"\n          example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n        name:\n          type: string\n          minLength: 1\n          description:\n            Short-hand name for an error, useful for discriminating between\n            errors with the same status code.\n          example: bad_request\n        errors:\n          type: array\n          items:\n            type: string\n          minItems: 1\n          description: One or more error messages indicating what went wrong.\n          example:\n            - \"some-field: is required\"\n      required:\n        - id\n        - name\n        - errors\n      example:\n        id: rate_limit_exceeded\n        name: rate_limit_exceeded\n        errors:\n          - You have exceeded the rate limit of 150 requests within a 10 second period,\n            and have been timed out for 60 seconds.\n    StabilityImageGenrationUpscaleCreative_Response_500:\n      type: object\n      properties:\n        id:\n          type: string\n          minLength: 1\n          description:\n            \"A unique identifier associated with this error. Please include\n            this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n\n            you file, as it will greatly assist us in diagnosing the root cause of\n            the problem.\"\n          example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n        name:\n          type: string\n          minLength: 1\n          description:\n            Short-hand name for an error, useful for discriminating between\n            errors with the same status code.\n          example: bad_request\n        errors:\n          type: array\n          items:\n            type: string\n          minItems: 1\n          description: One or more error messages indicating what went wrong.\n          example:\n            - \"some-field: is required\"\n      required:\n        - id\n        - name\n        - errors\n      example:\n        id: 2a1b2d4eafe2bc6ab4cd4d5c6133f513\n        name: internal_error\n        errors:\n          - An unexpected server error has occurred, please try again later.\n    StabilityImageGenrationUpscaleFast_Request:\n      type: object\n      properties:\n        image:\n          type: string\n          description: \"The image you wish to upscale.\n\n\n            Supported Formats:\n\n            - jpeg\n\n            - png\n\n            - webp\n\n\n            Validation Rules:\n\n            - Width must be between 32 and 1,536 pixels\n\n            - Height must be between 32 and 1,536 pixels\n\n            - Total pixel count must be between 1,024 and 1,048,576 pixels\"\n          format: binary\n          example: ./some/image.png\n        output_format:\n          type: string\n          enum:\n            - jpeg\n            - png\n            - webp\n          default: png\n          description: Dictates the `content-type` of the generated image.\n      required:\n        - image\n    StabilityImageGenrationUpscaleFast_Response_200:\n      type: object\n      properties:\n        image:\n          type: string\n          description: The generated image, encoded to base64.\n          example: AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1...\n        seed:\n          type: number\n          minimum: 0\n          maximum: 4294967294\n          default: 0\n          description: The seed used as random noise for this generation.\n          example: 343940597\n        finish_reason:\n          type: string\n          enum:\n            - SUCCESS\n            - CONTENT_FILTERED\n          description: \"The reason the generation finished.\n\n\n            - `SUCCESS` = successful generation.\n\n            - `CONTENT_FILTERED` = successful generation, however the output violated\n            our content moderation\n\n            policy and has been blurred as a result.\"\n          example: SUCCESS\n      required:\n        - image\n        - finish_reason\n    StabilityImageGenrationUpscaleFast_Response_400:\n      type: object\n      properties:\n        id:\n          type: string\n          minLength: 1\n          description:\n            \"A unique identifier associated with this error. Please include\n            this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n\n            you file, as it will greatly assist us in diagnosing the root cause of\n            the problem.\"\n          example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n        name:\n          type: string\n          minLength: 1\n          description:\n            Short-hand name for an error, useful for discriminating between\n            errors with the same status code.\n          example: bad_request\n        errors:\n          type: array\n          items:\n            type: string\n          minItems: 1\n          description: One or more error messages indicating what went wrong.\n          example:\n            - \"some-field: is required\"\n      required:\n        - id\n        - name\n        - errors\n    StabilityImageGenrationUpscaleFast_Response_413:\n      type: object\n      properties:\n        id:\n          type: string\n          minLength: 1\n          description:\n            \"A unique identifier associated with this error. Please include\n            this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n\n            you file, as it will greatly assist us in diagnosing the root cause of\n            the problem.\"\n          example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n        name:\n          type: string\n          minLength: 1\n          description:\n            Short-hand name for an error, useful for discriminating between\n            errors with the same status code.\n          example: bad_request\n        errors:\n          type: array\n          items:\n            type: string\n          minItems: 1\n          description: One or more error messages indicating what went wrong.\n          example:\n            - \"some-field: is required\"\n      required:\n        - id\n        - name\n        - errors\n      example:\n        id: 4212a4b66fbe1cedca4bf2133d35dca5\n        name: payload_too_large\n        errors:\n          - \"body: payloads cannot be larger than 10MiB in size\"\n    StabilityImageGenrationUpscaleFast_Response_422:\n      type: object\n      properties:\n        id:\n          type: string\n          minLength: 1\n          description:\n            \"A unique identifier associated with this error. Please include\n            this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n\n            you file, as it will greatly assist us in diagnosing the root cause of\n            the problem.\"\n          example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n        name:\n          type: string\n          minLength: 1\n          description:\n            Short-hand name for an error, useful for discriminating between\n            errors with the same status code.\n          example: bad_request\n        errors:\n          type: array\n          items:\n            type: string\n          minItems: 1\n          description: One or more error messages indicating what went wrong.\n          example:\n            - \"some-field: is required\"\n      required:\n        - id\n        - name\n        - errors\n    StabilityImageGenrationUpscaleFast_Response_429:\n      type: object\n      properties:\n        id:\n          type: string\n          minLength: 1\n          description:\n            \"A unique identifier associated with this error. Please include\n            this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n\n            you file, as it will greatly assist us in diagnosing the root cause of\n            the problem.\"\n          example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n        name:\n          type: string\n          minLength: 1\n          description:\n            Short-hand name for an error, useful for discriminating between\n            errors with the same status code.\n          example: bad_request\n        errors:\n          type: array\n          items:\n            type: string\n          minItems: 1\n          description: One or more error messages indicating what went wrong.\n          example:\n            - \"some-field: is required\"\n      required:\n        - id\n        - name\n        - errors\n      example:\n        id: rate_limit_exceeded\n        name: rate_limit_exceeded\n        errors:\n          - You have exceeded the rate limit of 150 requests within a 10 second period,\n            and have been timed out for 60 seconds.\n    StabilityGetResultResponse_202:\n      type: object\n      properties:\n        status:\n          type: string\n          enum:\n            - in-progress\n        id:\n          type: string\n          description: The ID of the generation result.\n          example: 1234567890\n    APIKey:\n      type: object\n      properties:\n        id:\n          type: string\n        name:\n          type: string\n        description:\n          type: string\n        key_prefix:\n          type: string\n        created_at:\n          type: string\n          format: date-time\n    APIKeyWithPlaintext:\n      allOf:\n        - $ref: \"#/components/schemas/APIKey\"\n        - type: object\n          properties:\n            plaintext_key:\n              type: string\n              description: The full API key (only returned at creation)\n    GeminiGenerateContentRequest:\n      type: object\n      required: [contents]\n      properties:\n        contents:\n          type: array\n          items:\n            $ref: \"#/components/schemas/GeminiContent\"\n        tools:\n          type: array\n          items:\n            $ref: \"#/components/schemas/GeminiTool\"\n        safetySettings:\n          type: array\n          items:\n            $ref: \"#/components/schemas/GeminiSafetySetting\"\n        generationConfig:\n          $ref: \"#/components/schemas/GeminiGenerationConfig\"\n        systemInstruction:\n          $ref: \"#/components/schemas/GeminiSystemInstructionContent\"\n        videoMetadata:\n          $ref: \"#/components/schemas/GeminiVideoMetadata\"\n        uploadImagesToStorage:\n          type: boolean\n          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.\n    GeminiGenerateContentResponse:\n      type: object\n      properties:\n        candidates:\n          type: array\n          items:\n            $ref: \"#/components/schemas/GeminiCandidate\"\n        promptFeedback:\n          $ref: \"#/components/schemas/GeminiPromptFeedback\"\n        usageMetadata:\n          $ref: \"#/components/schemas/GeminiUsageMetadata\"\n        modelVersion:\n          type: string\n          description: The model version used to generate the response.\n        createTime:\n          type: string\n          description: Timestamp when the response was created.\n        responseId:\n          type: string\n          description: Unique identifier for the response.\n    GeminiUsageMetadata:\n      type: object\n      properties:\n        promptTokenCount:\n          type: integer\n          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.\n        candidatesTokenCount:\n          type: integer\n          description: Number of tokens in the response(s).\n        toolUsePromptTokenCount:\n          type: integer\n          description: Number of tokens present in tool-use prompt(s).\n        thoughtsTokenCount:\n          type: integer\n          description: Number of tokens present in thoughts output.\n        cachedContentTokenCount:\n          type: integer\n          description: Output only. Number of tokens in the cached part in the input (the cached content).\n        promptTokensDetails:\n          type: array\n          items:\n            $ref: \"#/components/schemas/ModalityTokenCount\"\n          description: Breakdown of prompt tokens by modality.\n        candidatesTokensDetails:\n          type: array\n          items:\n            $ref: \"#/components/schemas/ModalityTokenCount\"\n          description: Breakdown of candidate tokens by modality.\n        totalTokenCount:\n          type: integer\n          description: Total number of tokens (prompt + candidates).\n        trafficType:\n          type: string\n          description: Traffic type used for the request (e.g., PROVISIONED_THROUGHPUT).\n    ModalityTokenCount:\n      type: object\n      properties:\n        modality:\n          $ref: \"#/components/schemas/Modality\"\n        tokenCount:\n          type: integer\n          description: Number of tokens for the given modality.\n    Modality:\n      type: string\n      enum:\n        - MODALITY_UNSPECIFIED\n        - TEXT\n        - IMAGE\n        - VIDEO\n        - AUDIO\n        - DOCUMENT\n      description: Type of input or output content modality.\n    GeminiSystemInstructionContent:\n      type: object\n      required: [role, parts]\n      description: |\n        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.\n      properties:\n        role:\n          type: string\n          description: |\n            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.\n          enum:\n            - user\n            - model\n          example: \"user\"\n        parts:\n          type: array\n          description: |\n            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.\n          items:\n            $ref: \"#/components/schemas/GeminiTextPart\"\n    GeminiContent:\n      type: object\n      required: [role, parts]\n      description: |\n        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.\n      properties:\n        role:\n          type: string\n          enum:\n            - user\n            - model\n          example: \"user\"\n        parts:\n          type: array\n          items:\n            $ref: \"#/components/schemas/GeminiPart\"\n    GeminiTool:\n      type: object\n      description: |\n        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.\n      properties:\n        functionDeclarations:\n          type: array\n          items:\n            $ref: \"#/components/schemas/GeminiFunctionDeclaration\"\n    GeminiSafetySetting:\n      type: object\n      description: |\n        Per request settings for blocking unsafe content. Enforced on GenerateContentResponse.candidates.\n      required: [category, threshold]\n      properties:\n        category:\n          $ref: \"#/components/schemas/GeminiSafetyCategory\"\n        threshold:\n          $ref: \"#/components/schemas/GeminiSafetyThreshold\"\n    GeminiSafetyCategory:\n      type: string\n      enum:\n        - HARM_CATEGORY_SEXUALLY_EXPLICIT\n        - HARM_CATEGORY_HATE_SPEECH\n        - HARM_CATEGORY_HARASSMENT\n        - HARM_CATEGORY_DANGEROUS_CONTENT\n    GeminiSafetyThreshold:\n      type: string\n      enum:\n        - OFF\n        - BLOCK_NONE\n        - BLOCK_LOW_AND_ABOVE\n        - BLOCK_MEDIUM_AND_ABOVE\n        - BLOCK_ONLY_HIGH\n    GeminiGenerationConfig:\n      type: object\n      properties:\n        temperature:\n          type: number\n          format: float\n          description: |\n            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\n          default: 1\n          minimum: 0\n          maximum: 2\n        topP:\n          type: number\n          format: float\n          description: |\n            If specified, nucleus sampling is used.\n            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.\n            Specify a lower value for less random responses and a higher value for more random responses.\n          default: 0.95\n          minimum: 0\n          maximum: 1\n        topK:\n          type: integer\n          description: |\n            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.\n          default: 40\n          minimum: 1\n          example: 40\n        maxOutputTokens:\n          type: integer\n          description: |\n            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.\n          minimum: 16\n          maximum: 8192\n          example: 2048\n        seed:\n          type: integer\n          description: |\n            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\n          example: 343940597\n        stopSequences:\n          type: array\n          items:\n            type: string\n        responseModalities:\n          type: array\n          items:\n            type: string\n            enum:\n              - TEXT\n              - IMAGE\n        imageConfig:\n          type: object\n          description: Configuration for image generation\n          properties:\n            imageOutputOptions:\n              type: object\n              description: Optional. The image output format for generated images.\n              properties:\n                mimeType:\n                  type: string\n                  description: Optional. The image format that the output should be saved as.\n                compressionQuality:\n                  type: integer\n                  description: Optional. The compression quality of the output image.\n            aspectRatio:\n              type: string\n              description: Aspect ratio for generated images\n            imageSize:\n              type: string\n              description: Optional. Specifies the size of generated images. Supported values are 1K, 2K, 4K. If not specified, the model will use default value 1K.\n        thinkingConfig:\n          type: object\n          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.\n          properties:\n            includeThoughts:\n              type: boolean\n              description: Optional. If true, the model will include its thoughts in the response.\n            thinkingBudget:\n              type: integer\n              description: Optional. The token budget for the model's thinking process. The model will make a best effort to stay within this budget.\n            thinkingLevel:\n              type: string\n              description: Optional. The thinking level for the model.\n              enum:\n                - THINKING_LEVEL_UNSPECIFIED\n                - LOW\n                - MEDIUM\n                - HIGH\n                - MINIMAL\n    GeminiVideoMetadata:\n      type: object\n      description: |\n        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.\n      properties:\n        startOffset:\n          $ref: \"#/components/schemas/GeminiOffset\"\n        endOffset:\n          $ref: \"#/components/schemas/GeminiOffset\"\n    GeminiOffset:\n      type: object\n      description: |\n        Represents a duration offset for video timeline positions.\n      properties:\n        seconds:\n          type: integer\n          description: |\n            Signed seconds of the span of time. Must be from -315,576,000,000 to +315,576,000,000 inclusive.\n          minimum: -315576000000\n          maximum: 315576000000\n          example: 60\n        nanos:\n          type: integer\n          description: |\n            Signed fractions of a second at nanosecond resolution. Negative second values with fractions must still have non-negative nanos values.\n          minimum: 0\n          maximum: 999999999\n          example: 0\n    GeminiCandidate:\n      type: object\n      properties:\n        content:\n          $ref: \"#/components/schemas/GeminiContent\"\n        finishReason:\n          type: string\n        safetyRatings:\n          type: array\n          items:\n            $ref: \"#/components/schemas/GeminiSafetyRating\"\n        citationMetadata:\n          $ref: \"#/components/schemas/GeminiCitationMetadata\"\n    GeminiMimeType:\n      type: string\n      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.\n      enum:\n        - application/pdf\n        - audio/mpeg\n        - audio/mp3\n        - audio/wav\n        - image/png\n        - image/jpeg\n        - image/webp\n        - text/plain\n        - video/mov\n        - video/mpeg\n        - video/mp4\n        - video/mpg\n        - video/avi\n        - video/wmv\n        - video/mpegps\n        - video/flv\n    GeminiPromptFeedback:\n      type: object\n      properties:\n        safetyRatings:\n          type: array\n          items:\n            $ref: \"#/components/schemas/GeminiSafetyRating\"\n        blockReason:\n          type: string\n        blockReasonMessage:\n          type: string\n    GeminiTextPart:\n      type: object\n      properties:\n        text:\n          type: string\n          description: A text prompt or code snippet.\n          example: \"Answer as concisely as possible\"\n    GeminiPart:\n      type: object\n      properties:\n        text:\n          type: string\n          description: A text prompt or code snippet.\n          example: \"Write a story about a robot learning to paint\"\n        inlineData:\n          $ref: \"#/components/schemas/GeminiInlineData\"\n        fileData:\n          $ref: \"#/components/schemas/GeminiFileData\"\n    GeminiFunctionDeclaration:\n      type: object\n      required: [name, parameters]\n      properties:\n        name:\n          type: string\n        description:\n          type: string\n        parameters:\n          type: object\n          description: JSON schema for the function parameters\n    GeminiSafetyRating:\n      type: object\n      properties:\n        category:\n          $ref: \"#/components/schemas/GeminiSafetyCategory\"\n        probability:\n          type: string\n          enum:\n            - NEGLIGIBLE\n            - LOW\n            - MEDIUM\n            - HIGH\n            - UNKNOWN\n          description: The probability that the content violates the specified safety category\n    GeminiCitationMetadata:\n      type: object\n      properties:\n        citations:\n          type: array\n          items:\n            $ref: \"#/components/schemas/GeminiCitation\"\n    GeminiInlineData:\n      type: object\n      description: |\n        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.\n      properties:\n        mimeType:\n          $ref: \"#/components/schemas/GeminiMimeType\"\n        data:\n          type: string\n          description: |\n            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\n          format: byte\n    GeminiFileData:\n      type: object\n      description: URI based data.\n      properties:\n        mimeType:\n          $ref: \"#/components/schemas/GeminiMimeType\"\n        fileUri:\n          type: string\n          description: URI\n\n    GeminiCitation:\n      type: object\n      properties:\n        startIndex:\n          type: integer\n        endIndex:\n          type: integer\n        uri:\n          type: string\n        title:\n          type: string\n        license:\n          type: string\n        publicationDate:\n          type: string\n          format: date\n        authors:\n          type: array\n          items:\n            type: string\n    Rodin3DGenerateRequest:\n      type: object\n      required:\n        - images\n      properties:\n        images:\n          type: string\n          description: The reference images to generate 3D Assets.\n        seed:\n          type: integer\n          description: Seed.\n        tier:\n          $ref: \"#/components/schemas/RodinTierType\"\n        material:\n          $ref: \"#/components/schemas/RodinMaterialType\"\n        quality:\n          $ref: \"#/components/schemas/RodinQualityType\"\n        mesh_mode:\n          $ref: \"#/components/schemas/RodinMeshModeType\"\n    RodinTierType:\n      type: string\n      description: Rodin Tier para options\n      enum: [Regular, Sketch, Detail, Smooth]\n    RodinMaterialType:\n      type: string\n      description: Rodin Material para options\n      enum: [PBR, Shaded]\n    RodinQualityType:\n      type: string\n      description: Rodin Quality para options\n      enum: [extra-low, low, medium, high]\n    RodinMeshModeType:\n      type: string\n      description: Rodin Mesh_Mode para options\n      enum: [Quad, Raw]\n    Rodin3DCheckStatusRequest:\n      type: object\n      required:\n        - subscription_key\n      properties:\n        subscription_key:\n          type: string\n          description: subscription from generate endpoint\n    Rodin3DDownloadRequest:\n      type: object\n      required:\n        - task_uuid\n      properties:\n        task_uuid:\n          type: string\n          description: Task UUID\n    Rodin3DGenerateResponse:\n      type: object\n      properties:\n        message:\n          type: string\n          description: message\n        prompt:\n          type: string\n          description: prompt\n        submit_time:\n          type: string\n          description: Time\n        uuid:\n          type: string\n          description: Task UUID\n        jobs:\n          $ref: \"#/components/schemas/RodinGenerateJobsData\"\n    RodinGenerateJobsData:\n      type: object\n      properties:\n        uuids:\n          type: array\n          description: subjobs uuid.\n          items:\n            type: string\n        subscription_key:\n          type: string\n          description: Subscription Key.\n    Rodin3DCheckStatusResponse:\n      type: object\n      properties:\n        jobs:\n          type: array\n          description: Details for the generation status.\n          items:\n            $ref: \"#/components/schemas/RodinCheckStatusJobItem\"\n    RodinCheckStatusJobItem:\n      type: object\n      properties:\n        uuid:\n          type: string\n          description: sub uuid\n        status:\n          $ref: \"#/components/schemas/RodinStatusOptions\"\n    RodinStatusOptions:\n      type: string\n      enum: [Done, Failed, Generating, Waiting]\n    Rodin3DDownloadResponse:\n      type: object\n      properties:\n        list:\n          type: array\n          items:\n            $ref: \"#/components/schemas/RodinResourceItem\"\n    RodinResourceItem:\n      type: object\n      properties:\n        url:\n          type: string\n          description: Download url\n        name:\n          type: string\n          description: File name\n    CreateAPIKeyRequest:\n      type: object\n      required:\n        - name\n      properties:\n        name:\n          type: string\n        description:\n          type: string\n    StabilityImageGenrationUpscaleFast_Response_500:\n      type: object\n      properties:\n        id:\n          type: string\n          minLength: 1\n          description:\n            \"A unique identifier associated with this error. Please include\n            this in any [support tickets](https://kb.stability.ai/knowledge-base/kb-tickets/new)\n\n            you file, as it will greatly assist us in diagnosing the root cause of\n            the problem.\"\n          example: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\n        name:\n          type: string\n          minLength: 1\n          description:\n            Short-hand name for an error, useful for discriminating between\n            errors with the same status code.\n          example: bad_request\n        errors:\n          type: array\n          items:\n            type: string\n          minItems: 1\n          description: One or more error messages indicating what went wrong.\n          example:\n            - \"some-field: is required\"\n      required:\n        - id\n        - name\n        - errors\n      example:\n        id: 2a1b2d4eafe2bc6ab4cd4d5c6133f513\n        name: internal_error\n        errors:\n          - An unexpected server error has occurred, please try again later.\n    StableAudio25TextToAudioRequest:\n      type: object\n      description: Request parameters for Stable Audio 2.5 text-to-audio generation\n      properties:\n        prompt:\n          type: string\n          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.\n          maxLength: 10000\n        duration:\n          type: number\n          description: Controls the duration in seconds of the generated audio.\n          minimum: 1\n          maximum: 190\n          default: 190\n        seed:\n          type: number\n          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.)\n          minimum: 0\n          maximum: 4294967294\n          default: 0\n        steps:\n          type: integer\n          description: Controls the number of sampling steps. For stable-audio-2.5 accepts steps between 4 and 8 (defaults to 8).\n          minimum: 4\n          maximum: 8\n          default: 8\n        cfg_scale:\n          type: number\n          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.\n          minimum: 1\n          maximum: 25\n          default: 1\n        model:\n          $ref: \"#/components/schemas/StableAudio25Model\"\n        output_format:\n          $ref: \"#/components/schemas/StableAudio25OutputFormat\"\n      required:\n        - prompt\n        - model\n    StableAudio25AudioToAudioRequest:\n      type: object\n      description: Request parameters for Stable Audio audio-to-audio transformation\n      properties:\n        prompt:\n          type: string\n          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.\n          maxLength: 10000\n        audio:\n          type: string\n          format: binary\n          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.\n        duration:\n          type: number\n          description: Controls the duration in seconds of the generated audio.\n          minimum: 1\n          maximum: 190\n          default: 190\n        seed:\n          type: number\n          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.)\n          minimum: 0\n          maximum: 4294967294\n          default: 0\n        steps:\n          type: integer\n          description: Controls the number of sampling steps. For stable-audio-2.5 accepts steps between 4 and 8 (defaults to 8).\n          minimum: 4\n          maximum: 8\n          default: 8\n        cfg_scale:\n          type: number\n          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.\n          minimum: 1\n          maximum: 25\n        model:\n          $ref: \"#/components/schemas/StableAudio25Model\"\n        output_format:\n          $ref: \"#/components/schemas/StableAudio25OutputFormat\"\n        strength:\n          type: number\n          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.\n          minimum: 0\n          maximum: 1\n          default: 1\n      required:\n        - prompt\n        - audio\n        - model\n    StableAudio25InpaintRequest:\n      type: object\n      description: Request parameters for Stable Audio 2.5 audio inpainting\n      properties:\n        prompt:\n          type: string\n          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.\n          maxLength: 10000\n        audio:\n          type: string\n          format: binary\n          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.\n        duration:\n          type: number\n          description: Controls the duration in seconds of the generated audio.\n          minimum: 1\n          maximum: 190\n          default: 190\n        seed:\n          type: number\n          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.)\n          minimum: 0\n          maximum: 4294967294\n          default: 0\n        steps:\n          type: integer\n          description: Controls the number of sampling steps.\n          minimum: 4\n          maximum: 8\n          default: 8\n        output_format:\n          $ref: \"#/components/schemas/StableAudio25OutputFormat\"\n        mask_start:\n          type: number\n          description: Start time in seconds for the audio segment to be inpainted.\n          minimum: 0\n          maximum: 190\n          default: 30\n        mask_end:\n          type: number\n          description: End time in seconds for the audio segment to be inpainted.\n          minimum: 0\n          maximum: 190\n          default: 190\n      required:\n        - prompt\n        - audio\n    StableAudio25AudioResponse:\n      type: object\n      description: Response from Stable Audio 2.5 audio generation\n      properties:\n        id:\n          type: string\n          description: Unique identifier for the generation request\n        audio:\n          type: string\n          format: byte\n          description: Base64-encoded audio data\n        finish_reason:\n          type: string\n          description: Reason for completion\n          enum: [\"SUCCESS\", \"ERROR\", \"CONTENT_FILTERED\"]\n    StableAudio25Model:\n      type: string\n      description: The model to use for generation\n      enum:\n        - stable-audio-2.5\n    StableAudio25OutputFormat:\n      type: string\n      description: Dictates the content-type of the generated audio\n      enum:\n        - mp3\n        - wav\n    ModelResponseProperties:\n      type: object\n      description: Common properties for model responses\n      properties:\n        model:\n          type: string\n          description: The model used to generate the response\n        instructions:\n          type: string\n\n          description: Instructions for the model on how to generate the response\n        max_output_tokens:\n          type: integer\n\n          description: Maximum number of tokens to generate\n        temperature:\n          type: number\n          minimum: 0\n          maximum: 2\n          default: 1\n          description: Controls randomness in the response\n        top_p:\n          type: number\n          minimum: 0\n          maximum: 1\n          default: 1\n          description: Controls diversity of the response via nucleus sampling\n        truncation:\n          type: string\n          enum: [disabled, auto]\n          default: disabled\n          description: How to handle truncation of the response\n    InputFileContent:\n      properties:\n        type:\n          type: string\n          enum:\n            - input_file\n          description: The type of the input item. Always `input_file`.\n          default: input_file\n          x-stainless-const: true\n        file_id:\n          type: string\n          description: The ID of the file to be sent to the model.\n        filename:\n          type: string\n          description: The name of the file to be sent to the model.\n        file_data:\n          type: string\n          description: |\n            The content of the file to be sent to the model.\n      type: object\n      required: &a1\n        - type\n      title: Input file\n      description: A file input to the model.\n    ResponseProperties:\n      type: object\n      properties:\n        previous_response_id:\n          type: string\n          description: |\n            The unique ID of the previous response to the model. Use this to\n            create multi-turn conversations. Learn more about\n            [conversation state](/docs/guides/conversation-state).\n\n        model:\n          description: >\n            Model ID used to generate the response, like `gpt-4o` or `o3`.\n            OpenAI\n\n            offers a wide range of models with different capabilities,\n            performance\n\n            characteristics, and price points. Refer to the [model\n            guide](/docs/models)\n\n            to browse and compare available models.\n\n          $ref: \"#/components/schemas/OpenAIModels\"\n\n        reasoning:\n          $ref: \"#/components/schemas/Reasoning\"\n\n        max_output_tokens:\n          description: >\n            An upper bound for the number of tokens that can be generated for a\n            response, including visible output tokens and [reasoning\n            tokens](/docs/guides/reasoning).\n          type: integer\n\n        instructions:\n          type: string\n          description: >\n            Inserts a system (or developer) message as the first item in the\n            model's context.\n\n\n            When using along with `previous_response_id`, the instructions from\n            a previous\n\n            response will not be carried over to the next response. This makes\n            it simple\n\n            to swap out system (or developer) messages in new responses.\n\n        text:\n          type: object\n          properties:\n            format:\n              $ref: \"#/components/schemas/TextResponseFormatConfiguration\"\n        tools:\n          type: array\n          items:\n            $ref: \"#/components/schemas/Tool\"\n        tool_choice:\n          description: >\n            How the model should select which tool (or tools) to use when\n            generating\n\n            a response. See the `tools` parameter to see how to specify which\n            tools\n\n            the model can call.\n          oneOf:\n            - $ref: \"#/components/schemas/ToolChoiceOptions\"\n            - $ref: \"#/components/schemas/ToolChoiceTypes\"\n            - $ref: \"#/components/schemas/ToolChoiceFunction\"\n        truncation:\n          type: string\n          description: >\n            The truncation strategy to use for the model response.\n\n            - `auto`: If the context of this response and previous ones exceeds\n              the model's context window size, the model will truncate the\n              response to fit the context window by dropping input items in the\n              middle of the conversation.\n            - `disabled` (default): If a model response will exceed the context\n            window\n              size for a model, the request will fail with a 400 error.\n          enum:\n            - auto\n            - disabled\n\n          default: disabled\n    TextResponseFormatConfiguration:\n      description: >\n        An object specifying the format that the model must output.\n\n\n        Configuring `{ \"type\": \"json_schema\" }` enables Structured Outputs,\n\n        which ensures the model will match your supplied JSON schema. Learn more\n        in the\n\n        [Structured Outputs guide](/docs/guides/structured-outputs).\n\n\n        The default format is `{ \"type\": \"text\" }` with no additional options.\n\n\n        **Not recommended for gpt-4o and newer models:**\n\n\n        Setting to `{ \"type\": \"json_object\" }` enables the older JSON mode,\n        which\n\n        ensures the message the model generates is valid JSON. Using\n        `json_schema`\n\n        is preferred for models that support it.\n      oneOf:\n        - $ref: \"#/components/schemas/ResponseFormatText\"\n        - $ref: \"#/components/schemas/TextResponseFormatJsonSchema\"\n        - $ref: \"#/components/schemas/ResponseFormatJsonObject\"\n    ResponseFormatJsonObject:\n      type: object\n      title: JSON object\n      description: >\n        JSON object response format. An older method of generating JSON\n        responses.\n\n        Using `json_schema` is recommended for models that support it. Note that\n        the\n\n        model will not generate JSON without a system or user message\n        instructing it\n\n        to do so.\n      properties:\n        type:\n          type: string\n          description: The type of response format being defined. Always `json_object`.\n          enum:\n            - json_object\n          x-stainless-const: true\n      required:\n        - type\n    ResponseFormatJsonSchema:\n      type: object\n      title: JSON schema\n      description: |\n        JSON Schema response format. Used to generate structured JSON responses.\n        Learn more about [Structured Outputs](/docs/guides/structured-outputs).\n      properties:\n        type:\n          type: string\n          default: json_schema\n          x-stainless-const: true\n        json_schema:\n          type: object\n          title: JSON schema\n          description: |\n            Structured Outputs configuration options, including a JSON Schema.\n          properties:\n            description:\n              type: string\n              description: >\n                A description of what the response format is for, used by the\n                model to\n\n                determine how to respond in the format.\n            name:\n              type: string\n              description: >\n                The name of the response format. Must be a-z, A-Z, 0-9, or\n                contain\n\n                underscores and dashes, with a maximum length of 64.\n            schema:\n              $ref: \"#/components/schemas/ResponseFormatJsonSchemaSchema\"\n            strict:\n              type: boolean\n\n              default: false\n              description: >\n                Whether to enable strict schema adherence when generating the\n                output.\n\n                If set to true, the model will always follow the exact schema\n                defined\n\n                in the `schema` field. Only a subset of JSON Schema is supported\n                when\n\n                `strict` is `true`. To learn more, read the [Structured Outputs\n\n                guide](/docs/guides/structured-outputs).\n          required:\n            - name\n      required:\n        - type\n        - json_schema\n    ResponseFormatJsonSchemaSchema:\n      type: object\n      title: JSON schema\n      description: |\n        The schema for the response format, described as a JSON Schema object.\n        Learn how to build JSON schemas [here](https://json-schema.org/).\n      additionalProperties: true\n    ResponseFormatText:\n      type: object\n      title: Text\n      description: |\n        Default response format. Used to generate text responses.\n      properties:\n        type:\n          type: string\n          description: The type of response format being defined. Always `text`.\n          enum:\n            - text\n          x-stainless-const: true\n      required:\n        - type\n    TextResponseFormatJsonSchema:\n      type: object\n      title: JSON schema\n      description: |\n        JSON Schema response format. Used to generate structured JSON responses.\n        Learn more about [Structured Outputs](/docs/guides/structured-outputs).\n      properties:\n        type:\n          type: string\n          description: The type of response format being defined. Always `json_schema`.\n          enum:\n            - json_schema\n          x-stainless-const: true\n        description:\n          type: string\n          description: >\n            A description of what the response format is for, used by the model\n            to\n\n            determine how to respond in the format.\n        name:\n          type: string\n          description: |\n            The name of the response format. Must be a-z, A-Z, 0-9, or contain\n            underscores and dashes, with a maximum length of 64.\n        schema:\n          $ref: \"#/components/schemas/ResponseFormatJsonSchemaSchema\"\n        strict:\n          type: boolean\n          default: false\n          description: >\n            Whether to enable strict schema adherence when generating the\n            output.\n\n            If set to true, the model will always follow the exact schema\n            defined\n\n            in the `schema` field. Only a subset of JSON Schema is supported\n            when\n\n            `strict` is `true`. To learn more, read the [Structured Outputs\n\n            guide](/docs/guides/structured-outputs).\n      required:\n        - type\n        - schema\n        - name\n\n    Reasoning:\n      type: object\n      description: |\n        **o-series models only**\n\n        Configuration options for\n        [reasoning models](https://platform.openai.com/docs/guides/reasoning).\n      title: Reasoning\n      properties:\n        effort:\n          $ref: \"#/components/schemas/ReasoningEffort\"\n        summary:\n          type: string\n          description: >\n            A summary of the reasoning performed by the model. This can be\n\n            useful for debugging and understanding the model's reasoning\n            process.\n\n            One of `auto`, `concise`, or `detailed`.\n          enum:\n            - auto\n            - concise\n            - detailed\n\n        generate_summary:\n          type: string\n          deprecated: true\n          description: >\n            **Deprecated:** use `summary` instead.\n\n\n            A summary of the reasoning performed by the model. This can be\n\n            useful for debugging and understanding the model's reasoning\n            process.\n\n            One of `auto`, `concise`, or `detailed`.\n          enum:\n            - auto\n            - concise\n            - detailed\n    ReasoningEffort:\n      type: string\n      enum:\n        - low\n        - medium\n        - high\n      default: medium\n      description: |\n        **o-series models only**\n\n        Constrains effort on reasoning for\n        [reasoning models](https://platform.openai.com/docs/guides/reasoning).\n        Currently supported values are `low`, `medium`, and `high`. Reducing\n        reasoning effort can result in faster responses and fewer tokens used\n        on reasoning in a response.\n    WebSearchPreviewTool:\n      properties:\n        type:\n          type: string\n          enum:\n            - web_search_preview\n            - web_search_preview_2025_03_11\n          description:\n            The type of the web search tool. One of `web_search_preview` or\n            `web_search_preview_2025_03_11`.\n          default: web_search_preview\n          x-stainless-const: true\n        search_context_size:\n          type: string\n          enum:\n            - low\n            - medium\n            - high\n          description:\n            High level guidance for the amount of context window space to use\n            for the search. One of `low`, `medium`, or `high`. `medium` is the\n            default.\n      type: object\n      required: *a1\n      title: Web search preview\n      description: This tool searches the web for relevant results to use in a\n        response. Learn more about the [web search\n        tool](https://platform.openai.com/docs/guides/tools-web-search).\n    ComputerUsePreviewTool:\n      properties:\n        type:\n          type: string\n          enum:\n            - computer_use_preview\n          description: The type of the computer use tool. Always `computer_use_preview`.\n          default: computer_use_preview\n          x-stainless-const: true\n        environment:\n          type: string\n          enum:\n            - windows\n            - mac\n            - linux\n            - ubuntu\n            - browser\n          description: The type of computer environment to control.\n        display_width:\n          type: integer\n          description: The width of the computer display.\n        display_height:\n          type: integer\n          description: The height of the computer display.\n      type: object\n      required:\n        - type\n        - environment\n        - display_width\n        - display_height\n      title: Computer use preview\n      description: A tool that controls a virtual computer. Learn more about the\n        [computer\n        tool](https://platform.openai.com/docs/guides/tools-computer-use).\n    Tool:\n      oneOf:\n        - $ref: \"#/components/schemas/FileSearchTool\"\n        - $ref: \"#/components/schemas/FunctionTool\"\n        - $ref: \"#/components/schemas/WebSearchPreviewTool\"\n        - $ref: \"#/components/schemas/ComputerUsePreviewTool\"\n      discriminator:\n        propertyName: type\n    ResponseErrorEvent:\n      type: object\n      description: Emitted when an error occurs.\n      properties:\n        type:\n          type: string\n          description: |\n            The type of the event. Always `error`.\n          enum:\n            - error\n          x-stainless-const: true\n        code:\n          type: string\n          description: |\n            The error code.\n\n        message:\n          type: string\n          description: |\n            The error message.\n        param:\n          type: string\n          description: |\n            The error parameter.\n\n      required:\n        - type\n        - code\n        - message\n        - param\n    ResponseOutputItemAddedEvent:\n      type: object\n      description: Emitted when a new output item is added.\n      properties:\n        type:\n          type: string\n          description: |\n            The type of the event. Always `response.output_item.added`.\n          enum:\n            - response.output_item.added\n          x-stainless-const: true\n        output_index:\n          type: integer\n          description: |\n            The index of the output item that was added.\n        item:\n          $ref: \"#/components/schemas/OutputItem\"\n          description: |\n            The output item that was added.\n      required:\n        - type\n        - output_index\n        - item\n    ResponseOutputItemDoneEvent:\n      type: object\n      description: Emitted when an output item is marked done.\n      properties:\n        type:\n          type: string\n          description: |\n            The type of the event. Always `response.output_item.done`.\n          enum:\n            - response.output_item.done\n          x-stainless-const: true\n        output_index:\n          type: integer\n          description: |\n            The index of the output item that was marked done.\n        item:\n          $ref: \"#/components/schemas/OutputItem\"\n          description: |\n            The output item that was marked done.\n      required:\n        - type\n        - output_index\n        - item\n    ToolChoiceFunction:\n      type: object\n      title: Function tool\n      description: |\n        Use this option to force the model to call a specific function.\n      properties:\n        type:\n          type: string\n          enum:\n            - function\n          description: For function calling, the type is always `function`.\n          x-stainless-const: true\n        name:\n          type: string\n          description: The name of the function to call.\n      required:\n        - type\n        - name\n    ToolChoiceOptions:\n      type: string\n      title: Tool choice mode\n      description: >\n        Controls which (if any) tool is called by the model.\n\n\n        `none` means the model will not call any tool and instead generates a\n        message.\n\n\n        `auto` means the model can pick between generating a message or calling\n        one or\n\n        more tools.\n\n\n        `required` means the model must call one or more tools.\n      enum:\n        - none\n        - auto\n        - required\n    ToolChoiceTypes:\n      type: object\n      title: Hosted tool\n      description: >\n        Indicates that the model should use a built-in tool to generate a\n        response.\n\n        [Learn more about built-in tools](/docs/guides/tools).\n      properties:\n        type:\n          type: string\n          description: |\n            The type of hosted tool the model should to use. Learn more about\n            [built-in tools](/docs/guides/tools).\n\n            Allowed values are:\n            - `file_search`\n            - `web_search_preview`\n            - `computer_use_preview`\n          enum:\n            - file_search\n            - web_search_preview\n            - computer_use_preview\n            - web_search_preview_2025_03_11\n      required:\n        - type\n\n    ResponseFailedEvent:\n      type: object\n      description: |\n        An event that is emitted when a response fails.\n      properties:\n        type:\n          type: string\n          description: |\n            The type of the event. Always `response.failed`.\n          enum:\n            - response.failed\n          x-stainless-const: true\n        response:\n          $ref: \"#/components/schemas/OpenAIResponse\"\n          description: |\n            The response that failed.\n      required:\n        - type\n        - response\n    ResponseInProgressEvent:\n      type: object\n      description: Emitted when the response is in progress.\n      properties:\n        type:\n          type: string\n          description: |\n            The type of the event. Always `response.in_progress`.\n          enum:\n            - response.in_progress\n          x-stainless-const: true\n        response:\n          $ref: \"#/components/schemas/OpenAIResponse\"\n          description: |\n            The response that is in progress.\n      required:\n        - type\n        - response\n    ResponseIncompleteEvent:\n      type: object\n      description: |\n        An event that is emitted when a response finishes as incomplete.\n      properties:\n        type:\n          type: string\n          description: |\n            The type of the event. Always `response.incomplete`.\n          enum:\n            - response.incomplete\n          x-stainless-const: true\n        response:\n          $ref: \"#/components/schemas/OpenAIResponse\"\n          description: |\n            The response that was incomplete.\n      required:\n        - type\n        - response\n    ResponseCreatedEvent:\n      type: object\n      description: An event that is emitted when a response is created.\n      properties:\n        type:\n          type: string\n          description: The type of the event. Always `response.created`.\n          enum:\n            - response.created\n          x-stainless-const: true\n        response:\n          $ref: \"#/components/schemas/OpenAIResponse\"\n          description: The response that was created.\n      required:\n        - type\n        - response\n\n    ResponseCompletedEvent:\n      type: object\n      description: Emitted when the model response is complete.\n      properties:\n        type:\n          type: string\n          description: The type of the event. Always `response.completed`.\n          enum:\n            - response.completed\n          x-stainless-const: true\n        response:\n          $ref: \"#/components/schemas/OpenAIResponse\"\n          description: Properties of the completed response.\n      required:\n        - type\n        - response\n\n    ResponseContentPartAddedEvent:\n      type: object\n      description: Emitted when a new content part is added.\n      properties:\n        type:\n          type: string\n          description: The type of the event. Always `response.content_part.added`.\n          enum:\n            - response.content_part.added\n          x-stainless-const: true\n        item_id:\n          type: string\n          description: The ID of the output item that the content part was added to.\n        output_index:\n          type: integer\n          description: The index of the output item that the content part was added to.\n        content_index:\n          type: integer\n          description: The index of the content part that was added.\n        part:\n          $ref: \"#/components/schemas/OutputContent\"\n          description: The content part that was added.\n      required:\n        - type\n        - item_id\n        - output_index\n        - content_index\n        - part\n\n    ResponseContentPartDoneEvent:\n      type: object\n      description: Emitted when a content part is done.\n      properties:\n        type:\n          type: string\n          description: The type of the event. Always `response.content_part.done`.\n          enum:\n            - response.content_part.done\n          x-stainless-const: true\n        item_id:\n          type: string\n          description: The ID of the output item that the content part was added to.\n        output_index:\n          type: integer\n          description: The index of the output item that the content part was added to.\n        content_index:\n          type: integer\n          description: The index of the content part that is done.\n        part:\n          $ref: \"#/components/schemas/OutputContent\"\n          description: The content part that is done.\n      required:\n        - type\n        - item_id\n        - output_index\n        - content_index\n        - part\n    ResponseTool:\n      oneOf:\n        - $ref: \"#/components/schemas/WebSearchTool\"\n        - $ref: \"#/components/schemas/FileSearchTool\"\n        - $ref: \"#/components/schemas/FunctionTool\"\n\n    WebSearchTool:\n      type: object\n      properties:\n        type:\n          type: string\n          enum: [web_search]\n          description: The type of tool\n        domains:\n          type: array\n          items:\n            type: string\n          description: Optional list of domains to restrict search to\n      required:\n        - type\n\n    FileSearchTool:\n      type: object\n      properties:\n        type:\n          type: string\n          enum: [file_search]\n          description: The type of tool\n        vector_store_ids:\n          type: array\n          items:\n            type: string\n          description: IDs of vector stores to search in\n      required:\n        - type\n        - vector_store_ids\n\n    FunctionTool:\n      type: object\n      properties:\n        type:\n          type: string\n          enum: [function]\n          description: The type of tool\n        name:\n          type: string\n          description: Name of the function\n        description:\n          type: string\n          description: Description of what the function does\n        parameters:\n          type: object\n          description: JSON Schema object describing the function parameters\n      required:\n        - type\n        - name\n        - parameters\n\n    OutputItem:\n      oneOf:\n        - $ref: \"#/components/schemas/OutputMessage\"\n        - $ref: \"#/components/schemas/FileSearchToolCall\"\n        - $ref: \"#/components/schemas/FunctionToolCall\"\n        - $ref: \"#/components/schemas/WebSearchToolCall\"\n        - $ref: \"#/components/schemas/ComputerToolCall\"\n        - $ref: \"#/components/schemas/ReasoningItem\"\n    WebSearchToolCall:\n      type: object\n      title: Web search tool call\n      description: |\n        The results of a web search tool call. See the\n        [web search guide](/docs/guides/tools-web-search) for more information.\n      properties:\n        id:\n          type: string\n          description: |\n            The unique ID of the web search tool call.\n        type:\n          type: string\n          enum:\n            - web_search_call\n          description: |\n            The type of the web search tool call. Always `web_search_call`.\n          x-stainless-const: true\n        status:\n          type: string\n          description: |\n            The status of the web search tool call.\n          enum:\n            - in_progress\n            - searching\n            - completed\n            - failed\n      required:\n        - id\n        - type\n        - status\n\n    FileSearchToolCall:\n      type: object\n      title: File search tool call\n      description: >\n        The results of a file search tool call. See the\n\n        [file search guide](/docs/guides/tools-file-search) for more\n        information.\n      properties:\n        id:\n          type: string\n          description: |\n            The unique ID of the file search tool call.\n        type:\n          type: string\n          enum:\n            - file_search_call\n          description: |\n            The type of the file search tool call. Always `file_search_call`.\n          x-stainless-const: true\n        status:\n          type: string\n          description: |\n            The status of the file search tool call. One of `in_progress`,\n            `searching`, `incomplete` or `failed`,\n          enum:\n            - in_progress\n            - searching\n            - completed\n            - incomplete\n            - failed\n        queries:\n          type: array\n          items:\n            type: string\n          description: |\n            The queries used to search for files.\n        results:\n          type: array\n          description: |\n            The results of the file search tool call.\n          items:\n            type: object\n            properties:\n              file_id:\n                type: string\n                description: |\n                  The unique ID of the file.\n              text:\n                type: string\n                description: |\n                  The text that was retrieved from the file.\n              filename:\n                type: string\n                description: |\n                  The name of the file.\n              score:\n                type: number\n                format: float\n                description: |\n                  The relevance score of the file - a value between 0 and 1.\n\n      required:\n        - id\n        - type\n        - status\n        - queries\n    FunctionToolCall:\n      type: object\n      title: Function tool call\n      description: >\n        A tool call to run a function. See the\n\n        [function calling guide](/docs/guides/function-calling) for more\n        information.\n      properties:\n        id:\n          type: string\n          description: |\n            The unique ID of the function tool call.\n        type:\n          type: string\n          enum:\n            - function_call\n          description: |\n            The type of the function tool call. Always `function_call`.\n          x-stainless-const: true\n        call_id:\n          type: string\n          description: |\n            The unique ID of the function tool call generated by the model.\n        name:\n          type: string\n          description: |\n            The name of the function to run.\n        arguments:\n          type: string\n          description: |\n            A JSON string of the arguments to pass to the function.\n        status:\n          type: string\n          description: |\n            The status of the item. One of `in_progress`, `completed`, or\n            `incomplete`. Populated when items are returned via API.\n          enum:\n            - in_progress\n            - completed\n            - incomplete\n      required:\n        - type\n        - call_id\n        - name\n        - arguments\n\n    OutputMessage:\n      type: object\n      properties:\n        type:\n          type: string\n          enum: [message]\n          description: The type of output item\n        role:\n          type: string\n          enum: [assistant]\n          description: The role of the message\n        content:\n          type: array\n          items:\n            $ref: \"#/components/schemas/OutputContent\"\n          description: The content of the message\n      required:\n        - type\n        - role\n        - content\n\n    OutputContent:\n      oneOf:\n        - $ref: \"#/components/schemas/OutputTextContent\"\n        - $ref: \"#/components/schemas/OutputAudioContent\"\n\n    OutputTextContent:\n      type: object\n      properties:\n        type:\n          type: string\n          enum: [output_text]\n          description: The type of output content\n        text:\n          type: string\n          description: The text content\n      required:\n        - type\n        - text\n\n    OutputAudioContent:\n      type: object\n      properties:\n        type:\n          type: string\n          enum: [output_audio]\n          description: The type of output content\n        data:\n          type: string\n          description: Base64-encoded audio data\n        transcript:\n          type: string\n          description: Transcript of the audio\n      required:\n        - type\n        - data\n        - transcript\n\n    ResponseUsage:\n      type: object\n      description: |\n        Represents token usage details including input tokens, output tokens,\n        a breakdown of output tokens, and the total tokens used.\n      properties:\n        input_tokens:\n          type: integer\n          description: The number of input tokens.\n        input_tokens_details:\n          type: object\n          description: A detailed breakdown of the input tokens.\n          properties:\n            cached_tokens:\n              type: integer\n              description: |\n                The number of tokens that were retrieved from the cache.\n                [More on prompt caching](/docs/guides/prompt-caching).\n          required:\n            - cached_tokens\n        output_tokens:\n          type: integer\n          description: The number of output tokens.\n        output_tokens_details:\n          type: object\n          description: A detailed breakdown of the output tokens.\n          properties:\n            reasoning_tokens:\n              type: integer\n              description: The number of reasoning tokens.\n          required:\n            - reasoning_tokens\n        total_tokens:\n          type: integer\n          description: The total number of tokens used.\n      required:\n        - input_tokens\n        - input_tokens_details\n        - output_tokens\n        - output_tokens_details\n        - total_tokens\n    OpenAIResponse:\n      type: object\n      description: A response from the model\n      allOf:\n        - $ref: \"#/components/schemas/ModelResponseProperties\"\n        - $ref: \"#/components/schemas/ResponseProperties\"\n        - type: object\n          properties:\n            id:\n              type: string\n              description: Unique identifier for this Response.\n            object:\n              type: string\n              description: The object type of this resource - always set to `response`.\n              enum:\n                - response\n              x-stainless-const: true\n            status:\n              type: string\n              description: The status of the response generation. One of `completed`, `failed`, `in_progress`, or `incomplete`.\n              enum:\n                - completed\n                - failed\n                - in_progress\n                - incomplete\n            created_at:\n              type: number\n              description: Unix timestamp (in seconds) of when this Response was created.\n            error:\n              $ref: \"#/components/schemas/ResponseError\"\n            incomplete_details:\n              type: object\n              nullable: true\n              description: |\n                Details about why the response is incomplete.\n              properties:\n                reason:\n                  type: string\n                  description: The reason why the response is incomplete.\n                  enum:\n                    - max_output_tokens\n                    - content_filter\n            output:\n              type: array\n              description: >\n                An array of content items generated by the model.\n\n\n                - The length and order of items in the `output` array is\n                dependent\n                  on the model's response.\n                - Rather than accessing the first item in the `output` array\n                and\n                  assuming it's an `assistant` message with the content generated by\n                  the model, you might consider using the `output_text` property where\n                  supported in SDKs.\n              items:\n                $ref: \"#/components/schemas/OutputItem\"\n            output_text:\n              type: string\n              nullable: true\n              description: >\n                SDK-only convenience property that contains the aggregated text\n                output\n\n                from all `output_text` items in the `output` array, if any are\n                present.\n\n                Supported in the Python and JavaScript SDKs.\n              x-oaiSupportedSDKs:\n                - python\n                - javascript\n            usage:\n              $ref: \"#/components/schemas/ResponseUsage\"\n            parallel_tool_calls:\n              type: boolean\n              description: |\n                Whether to allow the model to run tool calls in parallel.\n              default: true\n\n    ResponseError:\n      type: object\n      description: An error object returned when the model fails to generate a Response.\n\n      properties:\n        code:\n          $ref: \"#/components/schemas/ResponseErrorCode\"\n        message:\n          type: string\n          description: A human-readable description of the error.\n      required:\n        - code\n        - message\n\n    ResponseErrorCode:\n      type: string\n      description: The error code for the response.\n      enum:\n        - server_error\n        - rate_limit_exceeded\n        - invalid_prompt\n        - vector_store_timeout\n        - invalid_image\n        - invalid_image_format\n        - invalid_base64_image\n        - invalid_image_url\n        - image_too_large\n        - image_too_small\n        - image_parse_error\n        - image_content_policy_violation\n        - invalid_image_mode\n        - image_file_too_large\n        - unsupported_image_media_type\n        - empty_image_file\n        - failed_to_download_image\n        - image_file_not_found\n\n    OpenAIResponseStreamEvent:\n      type: object\n      description: Events that can be emitted during response streaming\n      anyOf:\n        - $ref: \"#/components/schemas/ResponseCreatedEvent\"\n        - $ref: \"#/components/schemas/ResponseInProgressEvent\"\n        - $ref: \"#/components/schemas/ResponseCompletedEvent\"\n        - $ref: \"#/components/schemas/ResponseFailedEvent\"\n        - $ref: \"#/components/schemas/ResponseIncompleteEvent\"\n        - $ref: \"#/components/schemas/ResponseOutputItemAddedEvent\"\n        - $ref: \"#/components/schemas/ResponseOutputItemDoneEvent\"\n        - $ref: \"#/components/schemas/ResponseContentPartAddedEvent\"\n        - $ref: \"#/components/schemas/ResponseContentPartDoneEvent\"\n        - $ref: \"#/components/schemas/ResponseErrorEvent\"\n\n    InputMessage:\n      type: object\n      properties:\n        type:\n          type: string\n          enum:\n            - message\n        role:\n          type: string\n          enum:\n            - user\n            - system\n            - developer\n        status:\n          type: string\n          enum:\n            - in_progress\n            - completed\n            - incomplete\n        content:\n          $ref: \"#/components/schemas/InputMessageContentList\"\n    InputMessageContentList:\n      type: array\n      title: Input item content list\n      description: >\n        A list of one or many input items to the model, containing different\n        content\n\n        types.\n      items:\n        $ref: \"#/components/schemas/InputContent\"\n    InputContent:\n      oneOf:\n        - $ref: \"#/components/schemas/InputTextContent\"\n        - $ref: \"#/components/schemas/InputImageContent\"\n        - $ref: \"#/components/schemas/InputFileContent\"\n    InputTextContent:\n      properties:\n        type:\n          type: string\n          enum:\n            - input_text\n          description: The type of the input item. Always `input_text`.\n          default: input_text\n          x-stainless-const: true\n        text:\n          type: string\n          description: The text input to the model.\n      type: object\n      required:\n        - type\n        - text\n      title: Input text\n      description: A text input to the model.\n    InputImageContent:\n      properties:\n        type:\n          type: string\n          enum:\n            - input_image\n          description: The type of the input item. Always `input_image`.\n          default: input_image\n          x-stainless-const: true\n        image_url:\n          type: string\n          description:\n            The URL of the image to be sent to the model. A fully qualified URL\n            or base64 encoded image in a data URL.\n        file_id:\n          type: string\n          description: The ID of the file to be sent to the model.\n        detail:\n          type: string\n          enum:\n            - low\n            - high\n            - auto\n          description:\n            The detail level of the image to be sent to the model. One of\n            `high`, `low`, or `auto`. Defaults to `auto`.\n      type: object\n      required:\n        - type\n        - detail\n      title: Input image\n      description: An image input to the model. Learn about [image\n        inputs](/docs/guides/vision).\n\n    InputMessageResource:\n      allOf:\n        - $ref: \"#/components/schemas/InputMessage\"\n        - type: object\n          properties:\n            id:\n              type: string\n              description: |\n                The unique ID of the message input.\n          required:\n            - id\n    ItemResource:\n      description: |\n        Content item used to generate a response.\n      oneOf:\n        - $ref: \"#/components/schemas/InputMessageResource\"\n        - $ref: \"#/components/schemas/OutputMessage\"\n        - $ref: \"#/components/schemas/FileSearchToolCall\"\n        - $ref: \"#/components/schemas/ComputerToolCall\"\n        - $ref: \"#/components/schemas/WebSearchToolCall\"\n        - $ref: \"#/components/schemas/FunctionToolCallResource\"\n      discriminator:\n        propertyName: type\n    FunctionToolCallResource:\n      allOf:\n        - $ref: \"#/components/schemas/FunctionToolCall\"\n        - type: object\n          properties:\n            id:\n              type: string\n              description: |\n                The unique ID of the function tool call.\n          required:\n            - id\n    ComputerToolCall:\n      type: object\n      title: Computer tool call\n      description: >\n        A tool call to a computer use tool. See the\n\n        [computer use guide](/docs/guides/tools-computer-use) for more\n        information.\n      properties:\n        type:\n          type: string\n          description: The type of the computer call. Always `computer_call`.\n          enum:\n            - computer_call\n          default: computer_call\n        id:\n          type: string\n          description: The unique ID of the computer call.\n        call_id:\n          type: string\n          description: |\n            An identifier used when responding to the tool call with output.\n        action:\n          type: object\n        status:\n          type: string\n          description: |\n            The status of the item. One of `in_progress`, `completed`, or\n            `incomplete`. Populated when items are returned via API.\n          enum:\n            - in_progress\n            - completed\n            - incomplete\n      required:\n        - type\n        - id\n        - action\n        - call_id\n        - pending_safety_checks\n        - status\n    ResponseItemList:\n      type: object\n      description: A list of Response items.\n      properties:\n        object:\n          type: string\n          description: The type of object returned, must be `list`.\n          enum:\n            - list\n          x-stainless-const: true\n        data:\n          type: array\n          description: A list of items used to generate this response.\n          items:\n            $ref: \"#/components/schemas/ItemResource\"\n        has_more:\n          type: boolean\n          description: Whether there are more items available.\n        first_id:\n          type: string\n          description: The ID of the first item in the list.\n        last_id:\n          type: string\n          description: The ID of the last item in the list.\n      required:\n        - object\n        - data\n        - has_more\n        - first_id\n        - last_id\n    Includable:\n      type: string\n      description: >\n        Specify additional output data to include in the model response.\n        Currently\n\n        supported values are:\n\n        - `file_search_call.results`: Include the search results of\n          the file search tool call.\n        - `message.input_image.image_url`: Include image urls from the input\n        message.\n\n        - `computer_call_output.output.image_url`: Include image urls from the\n        computer call output.\n      enum:\n        - file_search_call.results\n        - message.input_image.image_url\n        - computer_call_output.output.image_url\n    CreateModelResponseProperties:\n      allOf:\n        - $ref: \"#/components/schemas/ModelResponseProperties\"\n    InputItem:\n      oneOf:\n        - $ref: \"#/components/schemas/EasyInputMessage\"\n        - $ref: \"#/components/schemas/Item\"\n    Item:\n      type: object\n      description: |\n        Content item used to generate a response.\n      oneOf:\n        - $ref: \"#/components/schemas/InputMessage\"\n        - $ref: \"#/components/schemas/OutputMessage\"\n        - $ref: \"#/components/schemas/FileSearchToolCall\"\n        - $ref: \"#/components/schemas/ComputerToolCall\"\n        - $ref: \"#/components/schemas/WebSearchToolCall\"\n        - $ref: \"#/components/schemas/FunctionToolCall\"\n        - $ref: \"#/components/schemas/ReasoningItem\"\n    ReasoningItem:\n      type: object\n      description: >\n        A description of the chain of thought used by a reasoning model while\n        generating\n\n        a response.\n      title: Reasoning\n      properties:\n        type:\n          type: string\n          description: |\n            The type of the object. Always `reasoning`.\n          enum:\n            - reasoning\n          x-stainless-const: true\n        id:\n          type: string\n          description: |\n            The unique identifier of the reasoning content.\n        summary:\n          type: array\n          description: |\n            Reasoning text contents.\n          items:\n            type: object\n            properties:\n              type:\n                type: string\n                description: |\n                  The type of the object. Always `summary_text`.\n                enum:\n                  - summary_text\n                x-stainless-const: true\n              text:\n                type: string\n                description: >\n                  A short summary of the reasoning used by the model when\n                  generating\n\n                  the response.\n            required:\n              - type\n              - text\n        status:\n          type: string\n          description: |\n            The status of the item. One of `in_progress`, `completed`, or\n            `incomplete`. Populated when items are returned via API.\n          enum:\n            - in_progress\n            - completed\n            - incomplete\n      required:\n        - id\n        - summary\n        - type\n    EasyInputMessage:\n      type: object\n      title: Input message\n      description: >\n        A message input to the model with a role indicating instruction\n        following\n\n        hierarchy. Instructions given with the `developer` or `system` role take\n\n        precedence over instructions given with the `user` role. Messages with\n        the\n\n        `assistant` role are presumed to have been generated by the model in\n        previous\n\n        interactions.\n      properties:\n        role:\n          type: string\n          description: >\n            The role of the message input. One of `user`, `assistant`, `system`,\n            or\n\n            `developer`.\n          enum:\n            - user\n            - assistant\n            - system\n            - developer\n        content:\n          description: >\n            Text, image, or audio input to the model, used to generate a\n            response.\n\n            Can also contain previous assistant responses.\n          oneOf:\n            - type: string\n              title: Text input\n              description: |\n                A text input to the model.\n            - $ref: \"#/components/schemas/InputMessageContentList\"\n        type:\n          type: string\n          description: |\n            The type of the message input. Always `message`.\n          enum:\n            - message\n          x-stainless-const: true\n      required:\n        - role\n        - content\n    OpenAICreateResponse:\n      allOf:\n        - $ref: \"#/components/schemas/CreateModelResponseProperties\"\n        - $ref: \"#/components/schemas/ResponseProperties\"\n        - type: object\n          properties:\n            input:\n              description: >\n                Text, image, or file inputs to the model, used to generate a\n                response.\n\n\n                Learn more:\n\n                - [Text inputs and outputs](/docs/guides/text)\n\n                - [Image inputs](/docs/guides/images)\n\n                - [File inputs](/docs/guides/pdf-files)\n\n                - [Conversation state](/docs/guides/conversation-state)\n\n                - [Function calling](/docs/guides/function-calling)\n              oneOf:\n                - type: string\n                  title: Text input\n                  description: >\n                    A text input to the model, equivalent to a text input with\n                    the\n\n                    `user` role.\n                - type: array\n                  title: Input item list\n                  description: |\n                    A list of one or many input items to the model, containing\n                    different content types.\n                  items:\n                    $ref: \"#/components/schemas/InputItem\"\n            include:\n              type: array\n              description: >\n                Specify additional output data to include in the model response.\n                Currently\n\n                supported values are:\n\n                - `file_search_call.results`: Include the search results of\n                  the file search tool call.\n                - `message.input_image.image_url`: Include image urls from the\n                input message.\n\n                - `computer_call_output.output.image_url`: Include image urls\n                from the computer call output.\n              items:\n                $ref: \"#/components/schemas/Includable\"\n              nullable: true\n            usage:\n              $ref: \"#/components/schemas/ResponseUsage\"\n            parallel_tool_calls:\n              type: boolean\n              description: |\n                Whether to allow the model to run tool calls in parallel.\n              default: true\n              nullable: true\n            store:\n              type: boolean\n              description: >\n                Whether to store the generated model response for later\n                retrieval via\n\n                API.\n              default: true\n              nullable: true\n            stream:\n              description: |\n                If set to true, the model response data will be streamed to the client\n                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).\n                See the [Streaming section below](/docs/api-reference/responses-streaming)\n                for more information.\n              type: boolean\n              nullable: true\n              default: false\n          required:\n            - model\n            - input\n    OpenAIModels:\n      type: string\n      enum:\n        # Base GPT-4 Models\n        - gpt-4\n        - gpt-4-0314\n        - gpt-4-0613\n        - gpt-4-32k\n        - gpt-4-32k-0314\n        - gpt-4-32k-0613\n        - gpt-4-0125-preview\n        - gpt-4-turbo\n        - gpt-4-turbo-2024-04-09\n        - gpt-4-turbo-preview\n        - gpt-4-1106-preview\n        - gpt-4-vision-preview\n\n        # GPT-3.5 Models\n        - gpt-3.5-turbo\n        - gpt-3.5-turbo-16k\n        - gpt-3.5-turbo-0301\n        - gpt-3.5-turbo-0613\n        - gpt-3.5-turbo-1106\n        - gpt-3.5-turbo-0125\n        - gpt-3.5-turbo-16k-0613\n\n        # GPT-4.1 Models\n        - gpt-4.1\n        - gpt-4.1-mini\n        - gpt-4.1-nano\n        - gpt-4.1-2025-04-14\n        - gpt-4.1-mini-2025-04-14\n        - gpt-4.1-nano-2025-04-14\n\n        # O-Series Models\n        - o1\n        - o1-mini\n        - o1-preview\n        - o1-pro\n        - o1-2024-12-17\n        - o1-preview-2024-09-12\n        - o1-mini-2024-09-12\n        - o1-pro-2025-03-19\n\n        - o3\n        - o3-mini\n        - o3-2025-04-16\n        - o3-mini-2025-01-31\n\n        - o4-mini\n        - o4-mini-2025-04-16\n\n        # GPT-4O Models\n        - gpt-4o\n        - gpt-4o-mini\n        - gpt-4o-2024-11-20\n        - gpt-4o-2024-08-06\n        - gpt-4o-2024-05-13\n        - gpt-4o-mini-2024-07-18\n\n        # GPT-4O Special Purpose Models\n        - gpt-4o-audio-preview\n        - gpt-4o-audio-preview-2024-10-01\n        - gpt-4o-audio-preview-2024-12-17\n        - gpt-4o-mini-audio-preview\n        - gpt-4o-mini-audio-preview-2024-12-17\n        - gpt-4o-search-preview\n        - gpt-4o-mini-search-preview\n        - gpt-4o-search-preview-2025-03-11\n        - gpt-4o-mini-search-preview-2025-03-11\n\n        # Computer Use Models\n        - computer-use-preview\n        - computer-use-preview-2025-03-11\n\n        # GPT-5 models\n        - gpt-5\n        - gpt-5-mini\n        - gpt-5-nano\n        # Other\n        - chatgpt-4o-latest\n\n    MoonvalleyTextToVideoInferenceParams:\n      type: object\n      properties:\n        height:\n          type: integer\n          default: 1080\n          description: Height of the generated video in pixels\n        width:\n          type: integer\n          default: 1920\n          description: Width of the generated video in pixels\n        guidance_scale:\n          type: number\n          format: float\n          default: 10\n          description: Guidance scale for generation control\n        seed:\n          type: integer\n          description: \"Random seed for generation (default: random)\"\n          default: 9\n        steps:\n          type: integer\n          default: 80\n          description: Number of denoising steps\n        use_negative_prompts:\n          type: boolean\n          default: true\n          description: Whether to use negative prompts\n        negative_prompt:\n          type: string\n          description: Negative prompt text\n    MoonvalleyVideoToVideoInferenceParams:\n      type: object\n      properties:\n        guidance_scale:\n          type: number\n          format: float\n          default: 10\n          description: Guidance scale for generation control\n        seed:\n          type: integer\n          description: \"Random seed for generation (default: random)\"\n          default: 9\n        steps:\n          type: integer\n          default: 80\n          description: Number of denoising steps\n        use_negative_prompts:\n          type: boolean\n          default: true\n          description: Whether to use negative prompts\n        negative_prompt:\n          type: string\n          description: Negative prompt text\n        control_params:\n          type: object\n          properties:\n            motion_intensity:\n              type: integer\n              format: int32\n              default: 6\n              description: Intensity of motion control\n    MoonvalleyTextToImageRequest:\n      type: object\n      properties:\n        prompt_text:\n          type: string\n        image_url:\n          type: string\n        inference_params:\n          $ref: \"#/components/schemas/MoonvalleyTextToVideoInferenceParams\"\n        webhook_url:\n          type: string\n    MoonvalleyTextToVideoRequest:\n      type: object\n      properties:\n        prompt_text:\n          type: string\n        image_url:\n          type: string\n        inference_params:\n          $ref: \"#/components/schemas/MoonvalleyTextToVideoInferenceParams\"\n        webhook_url:\n          type: string\n    MoonvalleyVideoToVideoRequest:\n      type: object\n      required: [prompt_text, video_url, control_type]\n      properties:\n        prompt_text:\n          type: string\n          description: Describes the video to generate\n        video_url:\n          type: string\n          description: Url to control video\n        control_type:\n          type: string\n          enum: [motion_control, pose_control]\n          description: Supported types for video control\n        image_url:\n          type: string\n          description: Url to control image\n        inference_params:\n          $ref: \"#/components/schemas/MoonvalleyVideoToVideoInferenceParams\"\n          description: Parameters for video-to-video generation inference\n        webhook_url:\n          type: string\n          description: Optional webhook URL for notifications\n    MoonvalleyPromptResponse:\n      type: object\n      properties:\n        id:\n          type: string\n        status:\n          type: string\n        prompt_text:\n          type: string\n        output_url:\n          type: string\n        inference_params:\n          type: object\n        model_params:\n          type: object\n        meta:\n          type: object\n        frame_conditioning:\n          type: object\n        error:\n          type: object\n    MoonvalleyImageToVideoRequest:\n      allOf:\n        - $ref: \"#/components/schemas/MoonvalleyTextToVideoRequest\"\n        - type: object\n          properties:\n            keyframes:\n              type: object\n              additionalProperties:\n                type: object\n                properties:\n                  image_url:\n                    type: string\n    MoonvalleyResizeVideoRequest:\n      allOf:\n        - $ref: \"#/components/schemas/MoonvalleyVideoToVideoRequest\"\n        - type: object\n          properties:\n            frame_position:\n              type: array\n              items:\n                type: integer\n              minItems: 2\n              maxItems: 2\n            frame_resolution:\n              type: array\n              items:\n                type: integer\n              minItems: 2\n              maxItems: 2\n            scale:\n              type: array\n              items:\n                type: integer\n              minItems: 2\n              maxItems: 2\n    MoonvalleyUploadFileRequest:\n      type: object\n      properties:\n        file:\n          type: string\n          format: binary\n    MoonvalleyUploadFileResponse:\n      type: object\n      properties:\n        access_url:\n          type: string\n\n    GithubReleaseWebhook:\n      type: object\n      description: GitHub release webhook payload based on official webhook documentation\n      properties:\n        action:\n          type: string\n          enum:\n            [\n              published,\n              unpublished,\n              created,\n              edited,\n              deleted,\n              prereleased,\n              released,\n            ]\n          description: The action performed on the release\n        release:\n          type: object\n          description: The release object\n          properties:\n            id:\n              type: integer\n              description: The ID of the release\n            node_id:\n              type: string\n              description: The node ID of the release\n            url:\n              type: string\n              description: The API URL of the release\n            html_url:\n              type: string\n              description: The HTML URL of the release\n            assets_url:\n              type: string\n              description: The URL to the release assets\n            upload_url:\n              type: string\n              description: The URL to upload release assets\n            tag_name:\n              type: string\n              description: The tag name of the release\n            target_commitish:\n              type: string\n              description: The branch or commit the release was created from\n            name:\n              type: string\n              nullable: true\n              description: The name of the release\n            body:\n              type: string\n              nullable: true\n              description: The release notes/body\n            draft:\n              type: boolean\n              description: Whether the release is a draft\n            prerelease:\n              type: boolean\n              description: Whether the release is a prerelease\n            created_at:\n              type: string\n              format: date-time\n              description: When the release was created\n            published_at:\n              type: string\n              format: date-time\n              nullable: true\n              description: When the release was published\n            author:\n              $ref: \"#/components/schemas/GithubUser\"\n            tarball_url:\n              type: string\n              description: URL to the tarball\n            zipball_url:\n              type: string\n              description: URL to the zipball\n            assets:\n              type: array\n              items:\n                $ref: \"#/components/schemas/GithubReleaseAsset\"\n              description: Array of release assets\n          required:\n            - id\n            - node_id\n            - url\n            - html_url\n            - tag_name\n            - target_commitish\n            - draft\n            - prerelease\n            - created_at\n            - author\n            - tarball_url\n            - zipball_url\n            - assets\n        repository:\n          $ref: \"#/components/schemas/GithubRepository\"\n        sender:\n          $ref: \"#/components/schemas/GithubUser\"\n        organization:\n          $ref: \"#/components/schemas/GithubOrganization\"\n        installation:\n          $ref: \"#/components/schemas/GithubInstallation\"\n        enterprise:\n          $ref: \"#/components/schemas/GithubEnterprise\"\n      required:\n        - action\n        - release\n        - repository\n        - sender\n\n    GithubUser:\n      type: object\n      description: A GitHub user\n      properties:\n        login:\n          type: string\n          description: The user's login name\n        id:\n          type: integer\n          description: The user's ID\n        node_id:\n          type: string\n          description: The user's node ID\n        avatar_url:\n          type: string\n          description: URL to the user's avatar\n        gravatar_id:\n          type: string\n          nullable: true\n          description: The user's gravatar ID\n        url:\n          type: string\n          description: The API URL of the user\n        html_url:\n          type: string\n          description: The HTML URL of the user\n        type:\n          type: string\n          enum: [Bot, User, Organization]\n          description: The type of user\n        site_admin:\n          type: boolean\n          description: Whether the user is a site admin\n      required:\n        - login\n        - id\n        - node_id\n        - avatar_url\n        - url\n        - html_url\n        - type\n        - site_admin\n\n    GithubRepository:\n      type: object\n      description: A GitHub repository\n      properties:\n        id:\n          type: integer\n          description: The repository ID\n        node_id:\n          type: string\n          description: The repository node ID\n        name:\n          type: string\n          description: The name of the repository\n        full_name:\n          type: string\n          description: The full name of the repository (owner/repo)\n        private:\n          type: boolean\n          description: Whether the repository is private\n        owner:\n          $ref: \"#/components/schemas/GithubUser\"\n        html_url:\n          type: string\n          description: The HTML URL of the repository\n        description:\n          type: string\n          nullable: true\n          description: The repository description\n        fork:\n          type: boolean\n          description: Whether the repository is a fork\n        url:\n          type: string\n          description: The API URL of the repository\n        clone_url:\n          type: string\n          description: The clone URL of the repository\n        git_url:\n          type: string\n          description: The git URL of the repository\n        ssh_url:\n          type: string\n          description: The SSH URL of the repository\n        default_branch:\n          type: string\n          description: The default branch of the repository\n        created_at:\n          type: string\n          format: date-time\n          description: When the repository was created\n        updated_at:\n          type: string\n          format: date-time\n          description: When the repository was last updated\n        pushed_at:\n          type: string\n          format: date-time\n          description: When the repository was last pushed to\n      required:\n        - id\n        - node_id\n        - name\n        - full_name\n        - private\n        - owner\n        - html_url\n        - fork\n        - url\n        - clone_url\n        - git_url\n        - ssh_url\n        - default_branch\n        - created_at\n        - updated_at\n        - pushed_at\n\n    GithubReleaseAsset:\n      type: object\n      description: A GitHub release asset\n      properties:\n        id:\n          type: integer\n          description: The asset ID\n        node_id:\n          type: string\n          description: The asset node ID\n        name:\n          type: string\n          description: The name of the asset\n        label:\n          type: string\n          nullable: true\n          description: The label of the asset\n        content_type:\n          type: string\n          description: The content type of the asset\n        state:\n          type: string\n          enum: [uploaded, open]\n          description: The state of the asset\n        size:\n          type: integer\n          description: The size of the asset in bytes\n        download_count:\n          type: integer\n          description: The number of downloads\n        created_at:\n          type: string\n          format: date-time\n          description: When the asset was created\n        updated_at:\n          type: string\n          format: date-time\n          description: When the asset was last updated\n        browser_download_url:\n          type: string\n          description: The browser download URL\n        uploader:\n          $ref: \"#/components/schemas/GithubUser\"\n      required:\n        - id\n        - node_id\n        - name\n        - content_type\n        - state\n        - size\n        - download_count\n        - created_at\n        - updated_at\n        - browser_download_url\n        - uploader\n\n    GithubOrganization:\n      type: object\n      description: A GitHub organization\n      properties:\n        login:\n          type: string\n          description: The organization's login name\n        id:\n          type: integer\n          description: The organization ID\n        node_id:\n          type: string\n          description: The organization node ID\n        url:\n          type: string\n          description: The API URL of the organization\n        repos_url:\n          type: string\n          description: The API URL of the organization's repositories\n        events_url:\n          type: string\n          description: The API URL of the organization's events\n        hooks_url:\n          type: string\n          description: The API URL of the organization's hooks\n        issues_url:\n          type: string\n          description: The API URL of the organization's issues\n        members_url:\n          type: string\n          description: The API URL of the organization's members\n        public_members_url:\n          type: string\n          description: The API URL of the organization's public members\n        avatar_url:\n          type: string\n          description: URL to the organization's avatar\n        description:\n          type: string\n          nullable: true\n          description: The organization description\n      required:\n        - login\n        - id\n        - node_id\n        - url\n        - repos_url\n        - events_url\n        - hooks_url\n        - issues_url\n        - members_url\n        - public_members_url\n        - avatar_url\n\n    GithubInstallation:\n      type: object\n      description: A GitHub App installation\n      properties:\n        id:\n          type: integer\n          description: The installation ID\n        account:\n          $ref: \"#/components/schemas/GithubUser\"\n        repository_selection:\n          type: string\n          enum: [selected, all]\n          description: Repository selection for the installation\n        access_tokens_url:\n          type: string\n          description: The API URL for access tokens\n        repositories_url:\n          type: string\n          description: The API URL for repositories\n        html_url:\n          type: string\n          description: The HTML URL of the installation\n        app_id:\n          type: integer\n          description: The GitHub App ID\n        target_id:\n          type: integer\n          description: The target ID\n        target_type:\n          type: string\n          description: The target type\n        permissions:\n          type: object\n          description: The installation permissions\n        events:\n          type: array\n          items:\n            type: string\n          description: The events the installation subscribes to\n        created_at:\n          type: string\n          format: date-time\n          description: When the installation was created\n        updated_at:\n          type: string\n          format: date-time\n          description: When the installation was last updated\n        single_file_name:\n          type: string\n          nullable: true\n          description: The single file name if applicable\n      required:\n        - id\n        - account\n        - repository_selection\n        - access_tokens_url\n        - repositories_url\n        - html_url\n        - app_id\n        - target_id\n        - target_type\n        - permissions\n        - events\n        - created_at\n        - updated_at\n\n    GithubEnterprise:\n      type: object\n      description: A GitHub enterprise\n      properties:\n        id:\n          type: integer\n          description: The enterprise ID\n        slug:\n          type: string\n          description: The enterprise slug\n        name:\n          type: string\n          description: The enterprise name\n        node_id:\n          type: string\n          description: The enterprise node ID\n        avatar_url:\n          type: string\n          description: URL to the enterprise avatar\n        description:\n          type: string\n          nullable: true\n          description: The enterprise description\n        website_url:\n          type: string\n          nullable: true\n          description: The enterprise website URL\n        html_url:\n          type: string\n          description: The HTML URL of the enterprise\n        created_at:\n          type: string\n          format: date-time\n          description: When the enterprise was created\n        updated_at:\n          type: string\n          format: date-time\n          description: When the enterprise was last updated\n      required:\n        - id\n        - slug\n        - name\n        - node_id\n        - avatar_url\n        - html_url\n        - created_at\n        - updated_at\n\n    ReleaseNote:\n      type: object\n      properties:\n        id:\n          type: integer\n          description: Unique identifier for the release note\n        project:\n          type: string\n          enum: [comfyui, comfyui_frontend, desktop, cloud]\n          description: The project this release note belongs to\n        version:\n          type: string\n          description: The version of the release\n        attention:\n          type: string\n          enum: [low, medium, high]\n          description: The attention level for this release\n        content:\n          type: string\n          description: The content of the release note in markdown format\n        published_at:\n          type: string\n          format: date-time\n          description: When the release note was published\n      required:\n        - id\n        - project\n        - version\n        - attention\n        - content\n        - published_at\n\n    ViduCreation:\n      type: object\n      properties:\n        id:\n          type: string\n        url:\n          type: string\n        cover_url:\n          type: string\n        watermarked_url:\n          type: string\n        moderation_url:\n          type: array\n          items:\n            type: string\n    ViduState:\n      enum:\n        - created\n        - processing\n        - queueing\n        - success\n        - failed\n      type: string\n    ViduGetCreationsReply:\n      type: object\n      properties:\n        state:\n          $ref: \"#/components/schemas/ViduState\"\n        err_code:\n          type: string\n        creations:\n          type: array\n          items:\n            $ref: \"#/components/schemas/ViduCreation\"\n        id:\n          type: string\n    ViduTaskReply:\n      type: object\n      properties:\n        task_id:\n          type: string\n        state:\n          $ref: \"#/components/schemas/ViduState\"\n        model:\n          type: string\n        style:\n          enum:\n            - general\n            - anime\n          type: string\n        prompt:\n          type: string\n        images:\n          type: array\n          items:\n            type: string\n        duration:\n          type: integer\n          format: int32\n        seed:\n          type: integer\n          format: int32\n        aspect_ratio:\n          type: string\n        resolution:\n          type: string\n        movement_amplitude:\n          enum:\n            - auto\n            - small\n            - medium\n            - large\n          type: string\n        bgm:\n          type: boolean\n          description: Whether background music was added\n        payload:\n          type: string\n          description: Transparent transmission parameters\n        off_peak:\n          type: boolean\n          description: Off peak mode status\n        watermark:\n          type: boolean\n          description: Whether watermark was added\n        created_at:\n          type: string\n          format: date-time\n        credits:\n          type: integer\n          format: int32\n      required:\n        - task_id\n        - state\n        - credits\n    ViduTaskRequest:\n      type: object\n      properties:\n        model:\n          type: string\n          description: \"Model name: viduq3-pro, viduq2-pro-fast, viduq2-pro, viduq2-turbo, viduq1, viduq1-classic, vidu2.0\"\n        style:\n          enum:\n            - general\n            - anime\n          type: string\n        prompt:\n          type: string\n          description: Text prompt for video generation (max 2000 characters)\n        images:\n          type: array\n          items:\n            type: string\n          description: Images for img2video (accepts 1 image as start frame)\n        audio:\n          type: boolean\n          description: Enable direct audio-video generation capability (default true for q3 model)\n        audio_type:\n          type: string\n          enum:\n            - all\n            - speech_only\n            - sound_effect_only\n          description: \"Audio type when audio is true: all (sound effects + vocals), speech_only, sound_effect_only. Ineffective for q3 model\"\n        voice_id:\n          type: string\n          description: Voice ID for audio (ineffective for q3 model)\n        is_rec:\n          type: boolean\n          description: Use recommended prompt (consumes additional 10 credits)\n        bgm:\n          type: boolean\n          description: Add background music to generated video (ineffective for q3 model)\n        duration:\n          type: integer\n          format: int32\n          description: \"Video duration in seconds. viduq3-pro: 1-16, viduq2-pro-fast: 1-10, viduq2-pro/turbo: 1-8\"\n        seed:\n          type: integer\n          format: int32\n          description: Random seed (defaults to random if not specified)\n        aspect_ratio:\n          type: string\n        resolution:\n          type: string\n          description: \"Resolution: 360p, 540p, 720p, 1080p, 2K (availability depends on model and duration)\"\n        movement_amplitude:\n          enum:\n            - auto\n            - small\n            - medium\n            - large\n          type: string\n          description: Movement amplitude of objects in frame (ineffective for q2, q3 models)\n        payload:\n          type: string\n          description: Transparent transmission parameters (max 1048576 characters)\n        off_peak:\n          type: boolean\n          description: Off peak mode (lower cost, tasks generated within 48 hours)\n        watermark:\n          type: boolean\n          description: Add watermark to video (default false)\n        wm_position:\n          type: integer\n          format: int32\n          description: \"Watermark position: 1 (top left), 2 (top right), 3 (bottom right, default), 4 (bottom left)\"\n        wm_url:\n          type: string\n          description: Watermark image URL (uses default watermark if not provided)\n        meta_data:\n          type: string\n          description: Metadata identification, JSON format string for custom metadata\n        enhance:\n          type: boolean\n        callback_url:\n          type: string\n          description: Callback URL for task status updates\n        priority:\n          type: integer\n          format: int32\n    ViduExtendRequest:\n      type: object\n      properties:\n        model:\n          type: string\n          description: Model name (viduq2-pro or viduq2-turbo)\n        video_creation_id:\n          type: string\n          description: Vidu video_creation_id, required with video_url\n        video_url:\n          type: string\n          description: Any video URL, required with video_creation_id\n        images:\n          type: array\n          items:\n            type: string\n          description: Extended reference image to the end frame (only accepts 1 image)\n        prompt:\n          type: string\n          description: Text prompt for video generation (max 2000 characters)\n        duration:\n          type: integer\n          format: int32\n          description: Extended duration in seconds (1-7, default 5)\n        resolution:\n          type: string\n          description: Resolution (540p, 720p, 1080p)\n        payload:\n          type: string\n          description: Transparent transmission parameters (max 1048576 characters)\n        callback_url:\n          type: string\n          description: Callback URL for task status updates\n      required:\n        - model\n    ViduExtendReply:\n      type: object\n      properties:\n        task_id:\n          type: string\n        state:\n          $ref: \"#/components/schemas/ViduState\"\n        model:\n          type: string\n        video_creation_id:\n          type: string\n        video_url:\n          type: string\n        images:\n          type: array\n          items:\n            type: string\n        prompt:\n          type: string\n        duration:\n          type: integer\n          format: int32\n        resolution:\n          type: string\n        payload:\n          type: string\n        credits:\n          type: integer\n          format: int32\n        created_at:\n          type: string\n          format: date-time\n      required:\n        - task_id\n        - state\n        - credits\n    ViduImageSetting:\n      type: object\n      properties:\n        prompt:\n          type: string\n          description: Prompt for extending the previous frame\n        key_image:\n          type: string\n          description: Reference image for each key frame\n        duration:\n          type: integer\n          format: int32\n          description: Duration between key frames in seconds (2-7, default 5)\n      required:\n        - key_image\n    ViduMultiframeRequest:\n      type: object\n      properties:\n        model:\n          type: string\n          description: Model name (viduq2-pro or viduq2-turbo)\n        start_image:\n          type: string\n          description: The first frame image (Base64 or URL)\n        image_settings:\n          type: array\n          items:\n            $ref: \"#/components/schemas/ViduImageSetting\"\n          description: Configuration for intelligent multi-frame generation (2-9 frames)\n        resolution:\n          type: string\n          description: Video resolution (540p, 720p, 1080p)\n        payload:\n          type: string\n          description: Transparent transmission parameters (max 1048576 characters)\n        callback_url:\n          type: string\n          description: Callback URL for task status updates\n      required:\n        - model\n        - start_image\n        - image_settings\n    ViduMultiframeReply:\n      type: object\n      properties:\n        task_id:\n          type: string\n        state:\n          $ref: \"#/components/schemas/ViduState\"\n        model:\n          type: string\n        start_image:\n          type: string\n        image_settings:\n          type: array\n          items:\n            $ref: \"#/components/schemas/ViduImageSetting\"\n        resolution:\n          type: string\n        payload:\n          type: string\n        credits:\n          type: integer\n          format: int32\n        created_at:\n          type: string\n          format: date-time\n      required:\n        - task_id\n        - state\n        - credits\n    BytePlusImageGenerationRequest:\n      type: object\n      properties:\n        model:\n          type: string\n          enum:\n            - seedream-3-0-t2i-250415\n            - seededit-3-0-i2i-250628\n            - seedream-4-0-250828\n            - seedream-4-5-251128\n            - seedream-5-0-260128\n        prompt:\n          type: string\n          description: Text description for image generation or transformation\n        image:\n          oneOf:\n            - type: string\n              description: Single image (URL or Base64)\n            - type: array\n              items:\n                type: string\n              maxItems: 14\n              description: Multiple images (URLs or Base64) - supported by seedream-5.0-lite, 4.5 and 4.0\n          description: |\n            Seedream-5.0-lite, 4.5 and 4.0, and seededit-3.0-i2i support this parameter.\n\n            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.\n\n            • Image URL: Make sure that the image URL is accessible.\n            • Base64 encoding: The format must be data:image/<image format>;base64,<Base64 encoding>. Note: <image format> must be in lowercase, e.g., data:image/png;base64,<base64_image>.\n\n            An input image must meet the following requirements:\n            • Image format: jpeg, png (seedream-5.0-lite, 4.5 and 4.0 also support webp, bmp, tiff and gif)\n            • 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\n            • Width and height (px): > 14\n            • Size: No more than 10 MB\n            • Maximum of 14 reference images\n        size:\n          type: string\n          description: |\n            \"seedream-3-0-t2i-250415\": Specifies the dimensions (width x height in pixels) of the generated image. Must be between [512x512, 2048x2048]\n            \"seededit-3-0-i2i-250628\": The width and height pixels of the generated image. Currently only supports adaptive.\n            \"seedream-4-0-250828\": Set the specification for the generated image. Two methods are available but cannot be used together.\n              Method 1 | Specify the resolution. Optional values: 1K, 2K, 4K\n              Method 2 | Specify width and height in pixels. Default: 2048x2048, total pixels: [1024x1024, 4096x4096], aspect ratio: [1/16, 16]\n            \"seedream-4-5-251128\": Two methods available.\n              Method 1 | Specify the resolution. Optional values: 2K, 4K\n              Method 2 | Specify width and height in pixels. Default: 2048x2048, total pixels: [2560x1440, 4096x4096], aspect ratio: [1/16, 16]\n            \"seedream-5-0-260128\": Two methods available.\n              Method 1 | Specify the resolution. Optional values: 2K, 3K\n              Method 2 | Specify width and height in pixels. Default: 2048x2048, total pixels: [2560x1440, ~3072x3072], aspect ratio: [1/16, 16]\n        response_format:\n          type: string\n          enum:\n            - url\n            - b64_json\n          description: Specifies the format of the generated image returned in the response\n          default: \"url\"\n        seed:\n          type: integer\n          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.\"\n          default: -1\n        sequential_image_generation:\n          type: string\n          description: |\n            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:\n            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.\n            disabled: Disables batch generation feature. The model will only generate one image.\n        sequential_image_generation_options:\n          type: object\n          description: |\n            Only seedream-5.0-lite, 4.5 and 4.0 support this parameter.\n            Configuration for the batch image generation feature. This parameter is only effective when sequential_image_generation is set to auto.\n          properties:\n            max_images:\n              type: integer\n              description: \"Specifies the maximum number of images to generate in this request. Number of input reference images + Number of generated images ≤ 15.\"\n              minimum: 1\n              maximum: 15\n              default: 15\n        guidance_scale:\n          type: number\n          format: float\n          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.\"\n          minimum: 1\n          maximum: 10\n        watermark:\n          type: boolean\n          description: \"Specifies whether to add a watermark to the generated image. false = No watermark, true = Adds watermark with 'AI generated' label\"\n          default: true\n        output_format:\n          type: string\n          enum:\n            - png\n            - jpeg\n          description: \"Specifies the format of the output image. Only seedream-5.0-lite supports this parameter.\"\n          default: \"jpeg\"\n        stream:\n          type: boolean\n          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.\"\n          default: false\n        optimize_prompt_options:\n          type: object\n          description: |\n            Configuration for prompt optimization feature. Only seedream-5.0-lite/4.5 (only supports standard mode) and seedream-4.0 support this parameter.\n          properties:\n            mode:\n              type: string\n              enum:\n                - standard\n                - fast\n              description: \"Set the mode for the prompt optimization feature. standard = Higher quality, longer generation time. fast = Faster but at a more average quality.\"\n              default: \"standard\"\n      required:\n        - prompt\n        - model\n    BytePlusImageGenerationResponse:\n      type: object\n      properties:\n        model:\n          type: string\n          description: The model ID used for the request\n          example: \"seedream-3-0-t2i-250415\"\n        created:\n          type: integer\n          description: Unix timestamp (in seconds) indicating the time when the request was created\n        data:\n          type: array\n          items:\n            type: object\n            properties:\n              url:\n                type: string\n                format: uri\n                description: URL for image download (if response_format is \"url\")\n              b64_json:\n                type: string\n                description: Base64-encoded image data (if response_format is \"b64_json\")\n              size:\n                type: string\n                description: \"The width and height of the image in pixels, in the format <width>x<height>. Only seedream-5.0-lite, 4.5 and 4.0 support this parameter.\"\n          description: Contains information about the generated image(s)\n        usage:\n          type: object\n          properties:\n            generated_images:\n              type: integer\n              description: Number of images generated by the model\n            output_tokens:\n              type: integer\n              description: The number of tokens used for the picture generated by the model.\n            total_tokens:\n              type: integer\n              description: The total number of tokens consumed by this request.\n        error:\n          type: object\n          properties:\n            code:\n              type: string\n              description: Error code\n            message:\n              type: string\n              description: Error message\n          description: Error information (if any)\n    BytePlusVideoGenerationRequest:\n      type: object\n      properties:\n        model:\n          type: string\n          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\n          enum:\n            - seedance-1-5-pro-251215\n            - seedance-1-0-pro-250528\n            - seedance-1-0-lite-t2v-250428\n            - seedance-1-0-lite-i2v-250428\n            - seedance-1-0-pro-fast-251015\n        content:\n          type: array\n          description: The input content for the model to generate a video\n          items:\n            $ref: \"#/components/schemas/BytePlusVideoGenerationContent\"\n          minItems: 1\n        callback_url:\n          type: string\n          format: uri\n          description: Callback notification address for the result of this generation task\n        return_last_frame:\n          type: boolean\n          default: false\n          description: |\n            Whether to return the last frame image of the generated video.\n            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.\n            false: Does not return the last frame image of the generated video.\n        generate_audio:\n          type: boolean\n          default: true\n          description: |\n            Only supported by Seedance 1.5 pro. Whether the generated video includes audio synchronized with the visuals.\n            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.\"\n            false: The model outputs a silent video.\n      required:\n        - model\n        - content\n    BytePlusVideoGenerationContent:\n      type: object\n      properties:\n        type:\n          type: string\n          enum:\n          - text\n          - image_url\n          description: The type of the input content\n        text:\n          type: string\n          description: |\n            The input text information for the model. Includes text prompt and optional parameters.\n\n            Text prompt (required): Description of the video to be generated using Chinese and English characters.\n\n            Parameters (optional): Add --[parameters] after the text prompt to control video specifications:\n            - --resolution (--rs): 480p, 720p, 1080p (default: 720p)\n            - --ratio (--rt): 21:9, 16:9, 4:3, 1:1, 3:4, 9:16, 9:21, adaptive (default: 16:9 or adaptive)\n            - --duration (--dur): 3-12 seconds (default: 5)\n            - --framepersecond (--fps): 24 (default: 24)\n            - --watermark (--wm): true/false (default: false)\n            - --seed (--seed): -1 to 2^32-1 (default: -1)\n            - --camerafixed (--cf): true/false (default: false)\n\n            Example: \"A beautiful landscape --ratio 16:9 --resolution 720p --duration 5\"\n          maxLength: 4096\n        image_url:\n          type: object\n          properties:\n            url:\n              type: string\n              description: |\n                Image content for image-to-video generation (when type is \"image\")\n                Image URL: Make sure that the image URL is accessible.\n                Base64-encoded content: Format must be data:image/<format>;base64,<content>\n      required:\n        - type\n    BytePlusVideoGenerationResponse:\n      type: object\n      properties:\n        id:\n          type: string\n          description: The ID of the video generation task\n      required:\n        - id\n    BytePlusVideoGenerationQueryResponse:\n      type: object\n      properties:\n        id:\n          type: string\n          description: The ID of the video generation task\n        model:\n          type: string\n          description: The name and version of the model used by the task\n        status:\n          type: string\n          enum:\n            - queued\n            - running\n            - cancelled\n            - succeeded\n            - failed\n          description: The state of the task\n        error:\n          type: object\n          nullable: true\n          description: The error information. If the task succeeds, null is returned. If the task fails, the error information is returned.\n          properties:\n            code:\n              type: string\n              description: The error code\n            message:\n              type: string\n              description: The error message\n        created_at:\n          type: integer\n          description: The time when the task was created. The value is a UNIX timestamp in seconds.\n        updated_at:\n          type: integer\n          description: The time when the task was last updated. The value is a UNIX timestamp in seconds.\n        content:\n          type: object\n          description: The output after the video generation task is completed, which contains the download URL of the output video.\n          properties:\n            video_url:\n              type: string\n              description: The URL of the output video. For security purposes, the output video is cleared after 24 hours.\n        usage:\n          type: object\n          description: The token usage for the request\n          properties:\n            completion_tokens:\n              type: integer\n              description: The number of tokens generated by the model\n            total_tokens:\n              type: integer\n              description: For the video generation model, the number of input tokens is not calculated and defaults to 0. Therefore, total_tokens = completion_tokens.\n    WanVideoGenerationRequest:\n      type: object\n      properties:\n        model:\n          type: string\n          description: The ID of the model to call\n          enum:\n            - wan2.5-t2v-preview\n            - wan2.5-i2v-preview\n            - wan2.6-t2v\n            - wan2.6-i2v\n            - wan2.6-r2v\n        input:\n          type: object\n          description: Enter basic information, such as prompt words, etc.\n          properties:\n            prompt:\n              type: string\n              description: |\n                Text prompt words. Support Chinese and English, length not exceeding 800 characters.\n                For wan2.6-r2v with multiple reference videos, use 'character1', 'character2', etc. to refer to subjects\n                in the order of reference videos. Example: \"Character1 sings on the roadside, Character2 dances beside it\"\n              maxLength: 800\n            negative_prompt:\n              type: string\n              description: Reverse prompt words are used to describe content that you do not want to see in the video screen\n              maxLength: 500\n            audio_url:\n              type: string\n              description: \"Audio file download URL. Supported formats: mp3 and wav. Cannot be used with reference_video_urls.\"\n            img_url:\n              type: string\n              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.\"\n            template:\n              type: string\n              description: \"Video effect template name. Optional. Currently supported: squish, flying, carousel. When used, prompt parameter is ignored.\"\n            reference_video_urls:\n              type: array\n              description: |\n                Reference video URLs for wan2.6-r2v model only. Array of 1-3 video URLs.\n                Input restrictions:\n                - Format: mp4, mov\n                - Quantity: 1-3 videos\n                - Single video length: 2-30 seconds\n                - Single file size: max 30MB\n                - Cannot be used with audio_url\n                Reference duration: Single video max 5s, two videos max 2.5s each, three videos proportionally less.\n                Billing: Based on actual reference duration used.\n              items:\n                type: string\n              minItems: 1\n              maxItems: 3\n          required:\n            - prompt\n        parameters:\n          type: object\n          description: Video processing parameters\n          properties:\n            size:\n              type: string\n              description: |\n                Video resolution in format width*height. Supported resolutions vary by model:\n                For wan2.5 T2V: 480P (480*832, 832*480, 624*624), 720P, 1080P sizes\n                For wan2.6 T2V/R2V (no 480P):\n                  720P: 1280*720, 720*1280, 960*960, 1088*832, 832*1088\n                  1080P: 1920*1080, 1080*1920, 1440*1440, 1632*1248, 1248*1632\n            resolution:\n              type: string\n              description: |\n                Resolution level for I2V models. Supported values vary by model:\n                - wan2.5-i2v-preview: 480P, 720P, 1080P\n                - wan2.6-i2v: 720P, 1080P only (no 480P support)\n              enum:\n                - \"480P\"\n                - \"720P\"\n                - \"1080P\"\n            duration:\n              type: integer\n              description: |\n                The duration of the video generated, in seconds:\n                - wan2.5 models: 5 or 10 seconds\n                - wan2.6-t2v, wan2.6-i2v: 5, 10, or 15 seconds\n                - wan2.6-r2v: 5 or 10 seconds only (no 15s support)\n              enum: [5, 10, 15]\n              default: 5\n            prompt_extend:\n              type: boolean\n              description: Is it enabled prompt intelligent rewriting. Default is true\n              default: true\n            shot_type:\n              type: string\n              description: |\n                Intelligent multi-lens control. Only active when prompt_extend is enabled.\n                For wan2.6 models only.\n                - multi: Intelligent disassembly into multiple lenses (default)\n                - single: Single lens generation\n              enum:\n                - multi\n                - single\n              default: multi\n            seed:\n              type: integer\n              description: Random number seed, used to control the randomness of the model generated content\n              minimum: 0\n              maximum: 2147483647\n            watermark:\n              type: boolean\n              description: Whether to add a watermark logo, the watermark is located in the lower right corner\n              default: false\n            audio:\n              type: boolean\n              description: Whether to add audio to the video\n              default: true\n      required:\n        - model\n        - input\n    WanVideoGenerationResponse:\n      type: object\n      properties:\n        output:\n          type: object\n          properties:\n            task_id:\n              type: string\n              description: Task ID\n            task_status:\n              type: string\n              description: Task status\n              enum:\n                - PENDING\n                - RUNNING\n                - SUCCEEDED\n                - FAILED\n                - CANCELED\n                - UNKNOWN\n          required:\n            - task_id\n            - task_status\n        request_id:\n          type: string\n          description: Unique request identifier\n        code:\n          type: string\n          description: The error code for the failed request (not returned if request is successful)\n        message:\n          type: string\n          description: Detailed information about the failed request (not returned if request is successful)\n      required:\n        - output\n        - request_id\n    WanTaskQueryResponse:\n      type: object\n      properties:\n        request_id:\n          type: string\n          description: Unique request identifier\n        output:\n          type: object\n          properties:\n            task_id:\n              type: string\n              description: Task ID\n            task_status:\n              type: string\n              description: Task status\n              enum:\n                - PENDING\n                - RUNNING\n                - SUCCEEDED\n                - FAILED\n                - CANCELED\n                - UNKNOWN\n            submit_time:\n              type: string\n              description: Task submission time\n            scheduled_time:\n              type: string\n              description: Task execution time\n            end_time:\n              type: string\n              description: Task completion time\n            video_url:\n              type: string\n              description: Video URL for completed video generation tasks. Link validity period 24 hours\n            check_audio:\n              type: string\n              description: Audio URL for I2V tasks with audio generation\n            orig_prompt:\n              type: string\n              description: Original input prompt (for video tasks)\n            actual_prompt:\n              type: string\n              description: Actual prompt after intelligent rewriting (for video tasks)\n            results:\n              type: array\n              description: List of task results for image generation tasks\n              items:\n                type: object\n                properties:\n                  orig_prompt:\n                    type: string\n                    description: Original input prompt\n                  actual_prompt:\n                    type: string\n                    description: Actual prompt after intelligent rewriting (if enabled)\n                  url:\n                    type: string\n                    description: Generated image URL address\n                  code:\n                    type: string\n                    description: Image error code (returned when some tasks fail)\n                  message:\n                    type: string\n                    description: Image error information (returned when some tasks fail)\n            task_metrics:\n              type: object\n              description: Task result statistics for image generation tasks\n              properties:\n                TOTAL:\n                  type: integer\n                  description: Total number of tasks\n                SUCCEEDED:\n                  type: integer\n                  description: Number of successful tasks\n                FAILED:\n                  type: integer\n                  description: Number of failed tasks\n            code:\n              type: string\n              description: The error code for the failed request (not returned if request is successful)\n            message:\n              type: string\n              description: Detailed information about the failed request (not returned if request is successful)\n          required:\n            - task_id\n            - task_status\n        usage:\n          type: object\n          description: Output information statistics. Only successful results are counted\n          properties:\n            video_duration:\n              type: number\n              description: Duration of generated video in seconds (T2V tasks)\n            video_ratio:\n              type: string\n              description: Video resolution ratio (T2V tasks)\n            video_count:\n              type: integer\n              description: Number of generated videos (T2V tasks)\n            duration:\n              type: number\n              description: Duration of generated video in seconds (I2V tasks)\n            SR:\n              type: integer\n              description: Video resolution level (I2V tasks)\n            size:\n              type: string\n              description: Image resolution (T2I tasks)\n            image_count:\n              type: integer\n              description: Number of generated images (T2I tasks)\n      required:\n        - request_id\n        - output\n    WanImageGenerationRequest:\n      type: object\n      properties:\n        model:\n          type: string\n          description: The ID of the model to call for text-to-image generation\n          enum:\n            - wan2.5-t2i-preview\n        input:\n          type: object\n          description: Enter basic information, such as prompt words, etc.\n          properties:\n            prompt:\n              type: string\n              description: Positive prompt words to describe expected image elements and visual features. Support Chinese and English, length not exceeding 800 characters\n            negative_prompt:\n              type: string\n              description: Reverse prompt words to describe content that you do not want to see in the image\n          required:\n            - prompt\n        parameters:\n          type: object\n          description: Image processing parameters\n          properties:\n            size:\n              type: string\n              description: Output image resolution. Default is 1024*1024. Pixel range [512, 1440], up to 200 megapixels\n              default: \"1024*1024\"\n            n:\n              type: integer\n              description: Number of generated images. Range 1-4, default is 4\n              minimum: 1\n              maximum: 4\n              default: 4\n            seed:\n              type: integer\n              description: Random number seed to control randomness. Range [0, 2147483647]\n              minimum: 0\n              maximum: 2147483647\n            prompt_extend:\n              type: boolean\n              description: Enable prompt intelligent rewriting. Default is true\n              default: true\n            watermark:\n              type: boolean\n              description: Whether to add watermark logo in lower right corner\n              default: false\n      required:\n        - model\n        - input\n    WanImageGenerationResponse:\n      type: object\n      properties:\n        output:\n          type: object\n          properties:\n            task_id:\n              type: string\n              description: Task ID\n            task_status:\n              type: string\n              description: Task status\n              enum:\n                - PENDING\n                - RUNNING\n                - SUCCEEDED\n                - FAILED\n                - CANCELED\n                - UNKNOWN\n          required:\n            - task_id\n            - task_status\n        request_id:\n          type: string\n          description: Unique request identifier\n        code:\n          type: string\n          description: The error code for the failed request (not returned if request is successful)\n        message:\n          type: string\n          description: Detailed information about the failed request (not returned if request is successful)\n      required:\n        - request_id\n        - output\n    WanImage2ImageGenerationRequest:\n      type: object\n      properties:\n        model:\n          type: string\n          description: The ID of the model to call for image-to-image generation\n          enum:\n            - wan2.5-i2i-preview\n        input:\n          type: object\n          description: Enter basic information, such as prompt words, images, etc.\n          properties:\n            prompt:\n              type: string\n              description: Positive prompt words to describe expected image elements and visual features. Support Chinese and English, length not exceeding 2000 characters\n              maxLength: 2000\n            images:\n              type: array\n              description: Array of image URLs for image-to-image generation\n              items:\n                type: string\n                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.\n              minItems: 1\n              maxItems: 2\n            negative_prompt:\n              type: string\n              description: Reverse prompt words to describe content that you do not want to see in the image\n              maxLength: 500\n          required:\n            - prompt\n            - images\n        parameters:\n          type: object\n          description: Image processing parameters\n          properties:\n            size:\n              type: string\n              description: Output image resolution. Default is 1280*1280. Width and height must be between 384 and 5000 pixels.\n              default: \"1280*1280\"\n            n:\n              type: integer\n              description: Number of generated images. Range 1-4, default is 1\n              minimum: 1\n              maximum: 4\n              default: 1\n            seed:\n              type: integer\n              description: Random number seed to control randomness. Range [0, 2147483647]\n              minimum: 0\n              maximum: 2147483647\n            watermark:\n              type: boolean\n              description: Whether to add watermark logo in lower right corner\n              default: false\n      required:\n        - model\n        - input\n    WanImage2ImageGenerationResponse:\n      type: object\n      properties:\n        output:\n          type: object\n          properties:\n            task_id:\n              type: string\n              description: Task ID\n            task_status:\n              type: string\n              description: Task status\n              enum:\n                - PENDING\n                - RUNNING\n                - SUCCEEDED\n                - FAILED\n                - CANCELED\n                - UNKNOWN\n          required:\n            - task_id\n            - task_status\n        request_id:\n          type: string\n          description: Unique request identifier\n        code:\n          type: string\n          description: The error code for the failed request (not returned if request is successful)\n        message:\n          type: string\n          description: Detailed information about the failed request (not returned if request is successful)\n      required:\n        - request_id\n        - output\n    TopazEnhanceGenRequest:\n      type: object\n      properties:\n        output_format:\n          type: string\n          enum:\n            - jpeg\n            - jpg\n            - png\n            - tiff\n            - tif\n          description: The desired format of the output image\n          default: jpeg\n        subject_detection:\n          type: string\n          enum:\n            - \"All\"\n            - \"Foreground\"\n            - \"Background\"\n          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\n          default: \"All\"\n        face_enhancement:\n          type: boolean\n          description: By default, faces (if any) are enhanced during image processing as well. Set face_enhancement to false if you don't want this\n          default: true\n        face_enhancement_creativity:\n          type: number\n          minimum: 0\n          maximum: 1\n          description: Choose the level of creativity for face enhancement from 0 to 1. Defaults to 0, and is ignored if face_enhancement is false\n          default: 0\n        face_enhancement_strength:\n          type: number\n          minimum: 0\n          maximum: 1\n          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\n          default: 0.8\n        image:\n          type: string\n          format: binary\n          description: The image file to be processed. Supported formats - jpeg (or jpg), png, tiff (or tif)\n        source_id:\n          type: string\n          description: Unique identifier of the source image\n          example: \"d7b3b3b3-7b3b-4b3b-8b3b-3b3b3b3b3b3b\"\n        source_url:\n          type: string\n          description: The URL of the source image\n          example: \"https://example.com/image.jpg\"\n        model:\n          type: string\n          enum:\n            - \"Reimagine\"\n          description: The model to use for processing the image (Bloom - Creative Upscale)\n          default: \"Reimagine\"\n        output_height:\n          type: integer\n          minimum: 1\n          maximum: 32000\n          description: The desired height of the output image in pixels\n        output_width:\n          type: integer\n          minimum: 1\n          maximum: 32000\n          description: The desired width of the output image in pixels\n        crop_to_fill:\n          type: boolean\n          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\n          default: false\n        prompt:\n          type: string\n          description: Text prompt for creative upscaling guidance - available for Reimagine only\n          example: \"enter-your-prompt-here\"\n        creativity:\n          type: integer\n          minimum: 1\n          maximum: 9\n          description: Creativity settings range from 1 to 9 -  - available for Reimagine only\n          default: 3\n        face_preservation:\n          type: string\n          enum:\n            - \"true\"\n            - \"false\"\n          description: To preserve the identity of characters - available for Reimagine only (must be string \"true\" or \"false\" due to Topaz API requirement)\n          default: \"true\"\n        color_preservation:\n          type: string\n          enum:\n            - \"true\"\n            - \"false\"\n          description: To preserve the original color - available for Reimagine only (must be string \"true\" or \"false\" due to Topaz API requirement)\n          default: \"true\"\n      required:\n        - model\n    TopazEnhanceGenResponse:\n      type: object\n      properties:\n        process_id:\n          type: string\n          description: Unique identifier for the processing job\n          example: \"d7b3b3b3-7b3b-4b3b-8b3b-3b3b3b3b3b3b\"\n        source_id:\n          type: string\n          description: Unique identifier of the source image\n          example: \"d7b3b3b3-7b3b-4b3b-8b3b-3b3b3b3b3b3b\"\n        eta:\n          type: integer\n          description: Expected completion time in Unix timestamp\n          example: 1617220000\n      required:\n        - process_id\n        - eta\n    TopazStatusResponse:\n      type: object\n      properties:\n        process_id:\n          type: string\n          description: Unique identifier for the processing job\n        source_id:\n          type: string\n          description: Unique identifier of the source image\n        filename:\n          type: string\n          description: Original filename without extension\n        input_format:\n          type: string\n          description: Format of the input image\n        input_height:\n          type: integer\n          description: Height of the input image in pixels\n        input_width:\n          type: integer\n          description: Width of the input image in pixels\n        output_format:\n          type: string\n          description: Format of the output image\n        output_height:\n          type: integer\n          description: Height of the output image in pixels\n        output_width:\n          type: integer\n          description: Width of the output image in pixels\n        category:\n          type: string\n          description: Processing category (e.g., \"Enhance\")\n        model_type:\n          type: string\n          description: Type of model used (e.g., \"Generative\")\n        model:\n          type: string\n          description: Specific model used (e.g., \"Reimagine\")\n        subject_detection:\n          type: string\n          description: Subject detection setting\n        face_enhancement:\n          type: boolean\n          description: Whether face enhancement is enabled\n        face_enhancement_creativity:\n          type: number\n          description: Face enhancement creativity level\n        face_enhancement_strength:\n          type: number\n          description: Face enhancement strength level\n        crop_to_fill:\n          type: boolean\n          description: Whether crop to fill is enabled\n        options_json:\n          type: string\n          description: JSON string containing additional options\n        sync:\n          type: boolean\n          description: Whether this was a synchronous request\n        status:\n          type: string\n          enum:\n            - Pending\n            - Processing\n            - Completed\n            - Failed\n            - Cancelled\n          description: Current status of the processing job\n        progress:\n          type: number\n          minimum: 0\n          maximum: 100\n          description: Progress percentage (0-100)\n        eta:\n          type: integer\n          description: Expected completion time in Unix timestamp\n        creation_time:\n          type: integer\n          description: Creation time in Unix timestamp\n        modification_time:\n          type: integer\n          description: Last modification time in Unix timestamp\n        credits:\n          type: integer\n          description: Credits consumed for this job\n      required:\n        - process_id\n        - status\n        - credits\n    TopazDownloadResponse:\n      type: object\n      properties:\n        download_url:\n          type: string\n          description: Presigned URL to download the image\n          example: \"https://example.com/d7b3b3b3-7b3b-4b3b-8b3b-3b3b3b3b3b3b?presigned_headers\"\n        head_url:\n          type: string\n          description: Presigned URL to get image metadata\n          example: \"https://example.com/d7b3b3b3-7b3b-4b3b-8b3b-3b3b3b3b3b3b?presigned_headers\"\n        expiry:\n          type: integer\n          description: Expiration time of the presigned URLs in Unix timestamp\n          example: 1617220000\n      required:\n        - download_url\n        - expiry\n    TopazVideoSourceResolution:\n      type: object\n      required:\n        - width\n        - height\n      properties:\n        width:\n          type: integer\n          description: Width of the video in pixels\n          example: 1920\n        height:\n          type: integer\n          description: Height of the video in pixels\n          example: 1080\n    TopazVideoOutputResolution:\n      type: object\n      required:\n        - width\n        - height\n      properties:\n        width:\n          type: integer\n          description: Desired output width in pixels\n          example: 3840\n        height:\n          type: integer\n          description: Desired output height in pixels\n          example: 2160\n    TopazVideoEnhancementFilter:\n      type: object\n      required:\n        - model\n      properties:\n        model:\n          type: string\n          description: Short code name for AI model\n          enum:\n            - aaa-9\n            - ahq-12\n            - alq-13\n            - alqs-2\n            - amq-13\n            - amqs-2\n            - ddv-3\n            - dtd-4\n            - dtds-2\n            - dtv-4\n            - dtvs-2\n            - gcg-5\n            - ghq-5\n            - iris-2\n            - iris-3\n            - nxf-1\n            - nyx-3\n            - prob-4\n            - rhea-1\n            - rxl-1\n            - thd-3\n            - thf-4\n            - thm-2\n            - slf-1  # Starlight Fast\n            - slc-1  # Starlight Creative\n          example: prob-4\n        videoType:\n          type: string\n          enum:\n            - Progressive\n            - Interlaced\n            - ProgressiveInterlaced\n          description: Frame/field type of the video\n          example: Progressive\n        auto:\n          type: string\n          enum:\n            - Auto\n            - Manual\n            - Relative\n          description: Parameter mode of the selected model\n          example: Auto\n        fieldOrder:\n          type: string\n          enum:\n            - TopFirst\n            - BottomFirst\n            - Auto\n          description: Optional specification of field order for interlaced input videos\n          example: Auto\n        focusFixLevel:\n          type: string\n          enum:\n            - None\n            - Normal\n            - Strong\n          description: Downscales video input for stronger correction of blurred subjects\n          example: Normal\n        compression:\n          type: number\n          minimum: -1\n          maximum: 1\n          description: Adjust strength of compression recovery\n          example: 0.1\n        details:\n          type: number\n          minimum: -1\n          maximum: 1\n          description: Amount of detail reconstruction\n          example: 0.2\n        prenoise:\n          type: number\n          minimum: 0\n          maximum: 0.1\n          description: Adds noise to input to reduce over-smoothing\n          example: 0.01\n        noise:\n          type: number\n          minimum: -1\n          maximum: 1\n          description: Amount of noise reduction\n          example: 0.3\n        halo:\n          type: number\n          minimum: -1\n          maximum: 1\n          description: Amount of halo reduction\n          example: 0.4\n        preblur:\n          type: number\n          minimum: -1\n          maximum: 1\n          description: Adjust anti-aliasing and deblurring strength\n          example: 0.5\n        blur:\n          type: number\n          minimum: -1\n          maximum: 1\n          description: Amount of sharpness applied\n          example: 0.6\n        grain:\n          type: number\n          minimum: 0\n          maximum: 0.1\n          description: Adds grain after AI model processing\n          example: 0.02\n        grainSize:\n          type: number\n          minimum: 0\n          maximum: 5\n          description: Size of generated grain\n          example: 1\n        recoverOriginalDetailValue:\n          type: number\n          minimum: 0\n          maximum: 1\n          description: Reintroduce source details into the output video\n          example: 0.7\n        creativity:\n          type: string\n          enum:\n            - low\n            - high\n          description: Creativity level for Starlight Creative (slc-1) only\n        isOptimizedMode:\n          type: boolean\n          description: Set to true for Starlight Creative (slc-1) only\n    TopazVideoFrameInterpolationFilter:\n      type: object\n      required:\n        - model\n      properties:\n        model:\n          type: string\n          description: Short code name for AI model\n          enum:\n            - aion-1\n            - apf-2\n            - apo-8\n            - chf-3\n            - chr-2\n          example: apo-8\n        slowmo:\n          type: number\n          minimum: 1\n          maximum: 16\n          description: Slow motion factor applied to input video\n          example: 2\n        fps:\n          type: number\n          minimum: 15\n          maximum: 240\n          description: Output frame rate, does not increase duration\n          example: 60\n        duplicate:\n          type: boolean\n          description: Analyze input for duplicate frames and remove them\n          example: true\n        duplicateThreshold:\n          type: number\n          minimum: 0.001\n          maximum: 0.1\n          description: Sensitivity of detection for duplicate frames\n          example: 0.01\n    TopazCombinedCreateRequest:\n      oneOf:\n        - $ref: '#/components/schemas/TopazCreateRequestVideoSchema'\n        - $ref: '#/components/schemas/TopazCreateRequestImageSequenceSchema'\n    TopazCreateRequestVideoSchema:\n      title: Video AI\n      type: object\n      required:\n        - source\n        - filters\n        - output\n      properties:\n        source:\n          type: object\n          description: Source details for the video\n          required:\n            - container\n            - size\n            - duration\n            - frameCount\n            - frameRate\n            - resolution\n          properties:\n            container:\n              type: string\n              enum:\n                - mp4\n                - mov\n                - mkv\n              description: The container format of the video file\n              example: mp4\n            size:\n              type: integer\n              description: Size of the video file in bytes\n              example: 123456000\n            duration:\n              type: number\n              description: Duration of the video file in seconds\n              example: 600\n            frameCount:\n              type: number\n              description: Total number of frames in the video\n              example: 18000\n            frameRate:\n              type: number\n              description: Frame rate of the video\n              example: 30\n            resolution:\n              type: object\n              description: Resolution details of the video\n              required:\n                - width\n                - height\n              properties:\n                width:\n                  type: integer\n                  description: Width of the video in pixels\n                  example: 1920\n                height:\n                  type: integer\n                  description: Height of the video in pixels\n                  example: 1080\n            external:\n              $ref: '#/components/schemas/TopazExternalStorage'\n        filters:\n          $ref: '#/components/schemas/TopazInputFilters'\n        output:\n          $ref: '#/components/schemas/TopazOutputInformationVideo'\n        destination:\n          type: object\n          properties:\n            external:\n              $ref: '#/components/schemas/TopazExternalStorage'\n        overrides:\n          type: object\n          properties:\n            isPaidDiffusion:\n              type: boolean\n    TopazCreateRequestImageSequenceSchema:\n      title: Image Sequence\n      type: object\n      required:\n        - source\n        - filters\n        - output\n        - destination\n      properties:\n        source:\n          type: object\n          description: Source details for the video\n          required:\n            - container\n            - frameCount\n            - frameRate\n            - resolution\n            - external\n          properties:\n            container:\n              type: string\n              enum:\n                - DPX\n                - EXR\n                - JPEG\n                - PNG\n                - TIFF\n              description: The container format of the image files\n              example: TIFF\n            frameCount:\n              type: number\n              description: Total number of frames in the video, in this case, equal to the number of image files.\n              example: 18000\n            frameRate:\n              type: number\n              description: Frame rate of the video\n              example: 30\n            resolution:\n              type: object\n              description: Resolution details of the image\n              required:\n                - width\n                - height\n              properties:\n                width:\n                  type: integer\n                  description: Width of the image in pixels\n                  example: 1920\n                height:\n                  type: integer\n                  description: Height of the image in pixels\n                  example: 1080\n            startNumber:\n              type: integer\n              description: Optional starting frame number for image sequences\n              example: 120\n            endNumber:\n              type: integer\n              description: Optional ending frame number for image sequences\n              example: 120\n            external:\n              $ref: '#/components/schemas/TopazExternalStorage'\n        filters:\n          $ref: '#/components/schemas/TopazInputFilters'\n        output:\n          $ref: '#/components/schemas/TopazOutputInformationImageSequence'\n        destination:\n          type: object\n          properties:\n            external:\n              $ref: '#/components/schemas/TopazExternalStorage'\n    TopazExternalStorage:\n      type: object\n      required:\n        - provider\n        - credentials\n        - bucketName\n        - key\n      properties:\n        provider:\n          type: string\n          enum: [s3]\n          example: s3\n        credentials:\n          $ref: '#/components/schemas/TopazCredentialsS3'\n        bucketName:\n          type: string\n          example: galaxies\n        key:\n          type: string\n          description: |\n            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.\n            Keys for video requests must be valid characters supported by S3.\n          example: milky_way/%06d.tiff\n    TopazCredentialsS3:\n      type: object\n      required:\n        - roleArn\n        - externalId\n      properties:\n        roleArn:\n          type: string\n          description: AWS ARN of the role to assume\n          example: arn:aws:iam::123456789:role/topazlabs\n        externalId:\n          type: string\n          description: Kind of like a secret string for extra layer of security\n          example: MSTnuGztXtTU25XKjVfMJCsujv6VtAGtv1TGSjtOL6M=\n    TopazInputFilters:\n      type: array\n      description: Array of EnhancementFilter or FrameInterpolationFilter objects\n      items:\n        anyOf:\n          - $ref: '#/components/schemas/TopazVideoEnhancementFilter'\n          - $ref: '#/components/schemas/TopazVideoFrameInterpolationFilter'\n      example:\n        - model: prob-4\n          videoType: Progressive\n          auto: Auto\n          fieldOrder: Auto\n          focusFixLevel: Normal\n          compression: 0.1\n          details: 0.2\n          prenoise: 0.01\n          noise: 0.3\n          halo: 0.4\n          preblur: 0.5\n          blur: 0.6\n          grain: 0.02\n          grainSize: 1\n          recoverOriginalDetailValue: 0.7\n        - model: apo-8\n          slowmo: 2\n          fps: 60\n          duplicate: true\n          duplicateThreshold: 0.01\n    TopazOutputInformationVideo:\n      type: object\n      required:\n        - resolution\n        - frameRate\n        - audioCodec\n        - audioTransfer\n      properties:\n        resolution:\n          type: object\n          description: Desired output resolution\n          required:\n            - width\n            - height\n          properties:\n            width:\n              type: integer\n              description: Width in pixels. The maximum size depends on the encoder and can be referenced using the table below <table> <tr> <td>H264</td> <td>H265</td> <td>ProRes <td>AV1 <td>VP9 </tr> <tr> <td>4096</td> <td>8192</td> <td>16386</td> <td>16384</td> <td>8192</td> </tr> </table>\n              example: 7680\n            height:\n              type: integer\n              description: Height in pixels. The maximum size depends on the encoder and can be referenced using the table below <table> <tr> <td>H264</td> <td>H265</td> <td>ProRes <td>AV1 <td>VP9 </tr> <tr> <td>4096</td> <td>8192</td> <td>16386</td> <td>8704</td> <td>8192</td> </tr> </table>\n              example: 4320\n        frameRate:\n          type: number\n          description: Frame rate\n          example: 30\n        audioBitrate:\n          type: string\n          description: Audio bitrate, if audioTransfer is Copy or Convert. Default values for the codec are used if not provided.\n          example: \"320\"\n        audioCodec:\n          type: string\n          enum: [AAC, AC3, PCM]\n          description: __Required if audioTransfer is Copy or Convert.__\n          example: AAC\n        audioTransfer:\n          type: string\n          enum: [Copy, Convert, None]\n          example: Copy\n        codecId:\n          type: string\n          description: Video codec ID, if known. Defaults to videoEncoder.\n          example: h265-main-win-nvidia\n        videoEncoder:\n          type: string\n          enum:\n            - AV1\n            - FFV1\n            - H264\n            - H265\n            - ProRes\n            - QuickTime Animation\n            - QuickTime R210\n            - QuickTime V210\n            - VP9\n          example: H265\n        videoBitrate:\n          type: string\n          description: __Required if dynamicCompressionLevel is not provided.__ Constant bitrate, suffixed with \"k\" for kilobits or \"m\" for megabits per second.\n          example: \"1k\"\n        dynamicCompressionLevel:\n          type: string\n          enum: [Low, Mid, High]\n          description: __Required if videoBitrate is not provided.__ Automatic CQP selection.\n          example: Mid\n        videoProfile:\n          type: string\n          description: Codec profile specific to videoEncoder. The following are some combinations of available profiles based on the 'videoEncoder' selection <table> <tr> <td>H264</td> <td>H265</td> <td>ProRes <td>AV1 <td>VP9 </tr> <tr> <td>High</td> <td>Main, Main10</td> <td>422 Proxy, 422 LT, 422 Std, 422 HQ</td> <td>8-bit, 10-bit</td> <td>Good, Best</td> </tr> </table>\n          example: Main\n        cropToFit:\n          type: boolean\n          description: Center cropping to fit the output dimensions\n          example: true\n        container:\n          type: string\n          enum:\n            - mp4\n            - mov\n            - mkv\n          description: Desired output container\n          example: mp4\n    TopazOutputInformationImageSequence:\n      type: object\n      required:\n        - resolution\n        - frameRate\n      properties:\n        resolution:\n          type: object\n          description: Desired output resolution\n          required:\n            - width\n            - height\n          properties:\n            width:\n              type: integer\n              description: Width in pixels. The maximum size depends on the encoder and can be referenced using the table below <table> <tr> <td>H264</td> <td>H265</td> <td>ProRes <td>AV1 <td>VP9 </tr> <tr> <td>4096</td> <td>8192</td> <td>16386</td> <td>16384</td> <td>8192</td> </tr> </table>\n              example: 7680\n            height:\n              type: integer\n              description: Height in pixels. The maximum size depends on the encoder and can be referenced using the table below <table> <tr> <td>H264</td> <td>H265</td> <td>ProRes <td>AV1 <td>VP9 </tr> <tr> <td>4096</td> <td>8192</td> <td>16386</td> <td>8704</td> <td>8192</td> </tr> </table>\n              example: 4320\n        frameRate:\n          type: number\n          description: Frame rate\n          example: 30\n        codecId:\n          type: string\n          description: Video codec ID, if known. Defaults to videoEncoder.\n          example: h265-main-win-nvidia\n        videoEncoder:\n          type: string\n          enum:\n            - DPX\n            - EXR\n            - JPEG\n            - PNG\n            - TIFF\n          example: TIFF\n        videoProfile:\n          type: string\n          description: Codec profile specific to videoEncoder\n          example: Main\n        cropToFit:\n          type: boolean\n          description: Center cropping to fit the output dimensions\n          example: true\n        container:\n          type: string\n          enum:\n            - DPX\n            - EXR\n            - JPEG\n            - PNG\n            - TIFF\n          description: Desired output container, defaults to the input container\n          example: TIFF\n    TopazVideoCreateRequest:\n      $ref: '#/components/schemas/TopazCombinedCreateRequest'\n    TopazVideoRequestEstimates:\n      type: object\n      description: Lower and upper bound estimates\n      properties:\n        cost:\n          type: array\n          description: Cost range in credits\n          items:\n            type: integer\n          example: [10, 12]\n        time:\n          type: array\n          description: Time range in seconds\n          items:\n            type: integer\n          example: [600, 700]\n    TopazVideoCreateResponse:\n      type: object\n      properties:\n        requestId:\n          type: string\n          format: uuid\n          description: Unique identifier for the video processing request\n          example: \"c1f96dc2-c448-00e6-82ed-14ecb6403c62\"\n        estimates:\n          $ref: \"#/components/schemas/TopazVideoRequestEstimates\"\n      required:\n        - requestId\n        - estimates\n    TopazVideoAcceptResponse:\n      type: object\n      properties:\n        uploadId:\n          type: string\n          description: Upload ID for completing multi-part upload\n          example: \"GDlWC7qIaE6okS41Xf/ktpuS5XzTRabg\"\n        urls:\n          type: array\n          items:\n            type: string\n          description: URLs to PUT the parts to\n          example:\n            - \"https://videocloud.s3.amazonaws.com/source.mp4?uploadPart1\"\n            - \"https://videocloud.s3.amazonaws.com/source.mp4?uploadPart2\"\n        message:\n          type: string\n          description: Response message\n          example: \"Accepted\"\n      required:\n        - uploadId\n        - urls\n    TopazVideoCompleteUploadRequest:\n      type: object\n      required:\n        - uploadResults\n      properties:\n        md5Hash:\n          type: string\n          description: MD5 hash of the source video file in hex\n          example: 4d186321c1a7f0f354b297e8914ab240\n        uploadResults:\n          type: array\n          description: An array of part number and ETag pairs of the uploaded parts. ETags are returned by S3 upon upload of the part.\n          items:\n            type: object\n            required:\n              - partNum\n              - eTag\n            properties:\n              partNum:\n                type: integer\n                description: Part number of the uploaded part, starting from 1\n                example: 1\n              eTag:\n                type: string\n                description: eTag value returned by S3 upon upload of the part\n                example: \"d41d8cd98f00b204e9800998ecf8427e\"\n    TopazVideoCompleteUploadResponse:\n      type: object\n      properties:\n        message:\n          type: string\n          description: Confirmation message\n          example: \"Processing has been queued\"\n      required:\n        - message\n    TopazVideoEnhancedDownload:\n      type: object\n      description: Signed download URL to the enhanced video file\n      properties:\n        url:\n          type: string\n          example: \"https://videocloud.r2.cloudflarestorage.com/enhanced.mp4\"\n        expiresIn:\n          type: integer\n          description: TTL in milliseconds\n          example: 86400000\n        expiresAt:\n          type: integer\n          description: Time in milliseconds since UTC epoch\n          example: 1727213400000\n    TopazVideoStatusResponse:\n      type: object\n      properties:\n        status:\n          type: string\n          enum:\n            - requested\n            - accepted\n            - initializing\n            - preprocessing\n            - processing\n            - postprocessing\n            - complete\n            - canceling\n            - canceled\n            - failed\n          description: Current status of the video processing\n          example: \"processing\"\n        progress:\n          type: number\n          minimum: 0\n          maximum: 100\n          description: Total progress percentage\n          example: 82\n        estimates:\n          $ref: \"#/components/schemas/TopazVideoRequestEstimates\"\n        outputSize:\n          type: string\n          description: Size of output video\n          example: \"10 GB\"\n        averageFps:\n          type: number\n          description: Average processing speed of each node\n          example: 1.23\n        combinedFps:\n          type: number\n          description: Combined processing speed of all nodes\n          example: 12.34\n        message:\n          type: string\n          example: \"Processing\"\n        download:\n          $ref: \"#/components/schemas/TopazVideoEnhancedDownload\"\n      required:\n        - status\n\n    # Meshy API Schemas\n    MeshyTextTo3DRequest:\n      oneOf:\n        - $ref: \"#/components/schemas/MeshyTextTo3DPreviewRequest\"\n        - $ref: \"#/components/schemas/MeshyTextTo3DRefineRequest\"\n      discriminator:\n        propertyName: mode\n        mapping:\n          preview: \"#/components/schemas/MeshyTextTo3DPreviewRequest\"\n          refine: \"#/components/schemas/MeshyTextTo3DRefineRequest\"\n\n    MeshyTextTo3DPreviewRequest:\n      type: object\n      required:\n        - mode\n        - prompt\n      properties:\n        mode:\n          type: string\n          description: This field should be set to \"preview\" when creating a preview task.\n          enum:\n            - preview\n        prompt:\n          type: string\n          description: Describe what kind of object the 3D model is. Maximum 600 characters.\n          maxLength: 600\n        art_style:\n          $ref: \"#/components/schemas/MeshyArtStyle\"\n        ai_model:\n          $ref: \"#/components/schemas/MeshyAiModel\"\n        topology:\n          $ref: \"#/components/schemas/MeshyTopology\"\n        target_polycount:\n          type: integer\n          description: Specify the target number of polygons in the generated model. Valid range is 100 to 300,000.\n          minimum: 100\n          maximum: 300000\n          default: 30000\n        should_remesh:\n          type: boolean\n          description: Controls whether to enable the remesh phase. When false, returns highest-precision triangular mesh.\n          default: true\n        symmetry_mode:\n          $ref: \"#/components/schemas/MeshySymmetryMode\"\n        pose_mode:\n          $ref: \"#/components/schemas/MeshyPoseMode\"\n        is_a_t_pose:\n          type: boolean\n          description: Deprecated. Use pose_mode instead. Whether to generate the model in an A/T pose.\n          default: false\n        moderation:\n          type: boolean\n          description: When true, input content will be screened for potentially harmful content.\n          default: false\n\n    MeshyTextTo3DRefineRequest:\n      type: object\n      required:\n        - mode\n        - preview_task_id\n      properties:\n        mode:\n          type: string\n          description: This field should be set to \"refine\" when creating a refine task.\n          enum:\n            - refine\n        preview_task_id:\n          type: string\n          description: The corresponding preview task id. The status of the given preview task must be SUCCEEDED.\n        enable_pbr:\n          type: boolean\n          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.\n          default: false\n        texture_prompt:\n          type: string\n          description: Provide an additional text prompt to guide the texturing process. Maximum 600 characters.\n          maxLength: 600\n        texture_image_url:\n          type: string\n          description: Provide a 2d image to guide the texturing process. Supports .jpg, .jpeg, .png formats or base64-encoded data URI.\n        ai_model:\n          $ref: \"#/components/schemas/MeshyAiModel\"\n        moderation:\n          type: boolean\n          description: When true, input content will be screened for potentially harmful content.\n          default: false\n\n    MeshyArtStyle:\n      type: string\n      description: Describe your desired art style of the object.\n      enum:\n        - realistic\n        - sculpture\n      default: realistic\n\n    MeshyAiModel:\n      type: string\n      description: ID of the model to use.\n      enum:\n        - meshy-5\n        - latest\n      default: latest\n\n    MeshyTopology:\n      type: string\n      description: Specify the topology of the generated model.\n      enum:\n        - quad\n        - triangle\n      default: triangle\n\n    MeshySymmetryMode:\n      type: string\n      description: Controls symmetry behavior during model generation.\n      enum:\n        - \"off\"\n        - auto\n        - \"on\"\n      default: auto\n\n    MeshyPoseMode:\n      type: string\n      description: Specify the pose mode for the generated model.\n      enum:\n        - a-pose\n        - t-pose\n        - \"\"\n\n    MeshyTextTo3DCreateResponse:\n      type: object\n      properties:\n        result:\n          type: string\n          description: The task id of the newly created Text to 3D task.\n      required:\n        - result\n\n    MeshyTextTo3DTask:\n      type: object\n      properties:\n        id:\n          type: string\n          description: Unique identifier for the task.\n        type:\n          type: string\n          description: Type of the Text to 3D task.\n          enum:\n            - text-to-3d-preview\n            - text-to-3d-refine\n        model_urls:\n          $ref: \"#/components/schemas/MeshyModelUrls\"\n        prompt:\n          type: string\n          description: The unmodified prompt that was used to create the task.\n        negative_prompt:\n          type: string\n          description: Deprecated field maintained for backward compatibility.\n        art_style:\n          type: string\n          description: The unmodified art_style that was used to create the preview task.\n        texture_richness:\n          type: string\n          description: Deprecated field maintained for backward compatibility.\n        texture_prompt:\n          type: string\n          description: Additional text prompt provided to guide the texturing process during the refine stage.\n        texture_image_url:\n          type: string\n          description: Downloadable URL to the texture image that was used to guide the texturing process.\n        thumbnail_url:\n          type: string\n          description: Downloadable URL to the thumbnail image of the model file.\n        video_url:\n          type: string\n          description: Deprecated field returning the downloadable URL to the preview video.\n        progress:\n          type: integer\n          description: Progress of the task. 0 if not started, 100 when succeeded.\n          minimum: 0\n          maximum: 100\n        started_at:\n          type: integer\n          description: Timestamp of when the task was started, in milliseconds. 0 if not started.\n        created_at:\n          type: integer\n          description: Timestamp of when the task was created, in milliseconds.\n        finished_at:\n          type: integer\n          description: Timestamp of when the task was finished, in milliseconds. 0 if not finished.\n        status:\n          $ref: \"#/components/schemas/MeshyTaskStatus\"\n        texture_urls:\n          type: array\n          items:\n            $ref: \"#/components/schemas/MeshyTextureUrls\"\n          description: An array of texture URL objects that are generated from the task.\n        preceding_tasks:\n          type: integer\n          description: The count of preceding tasks. Only meaningful when status is PENDING.\n        task_error:\n          $ref: \"#/components/schemas/MeshyTaskError\"\n      required:\n        - id\n        - status\n\n    MeshyModelUrls:\n      type: object\n      description: Downloadable URLs to the textured 3D model files generated by Meshy.\n      properties:\n        glb:\n          type: string\n          description: Downloadable URL to the GLB file.\n        fbx:\n          type: string\n          description: Downloadable URL to the FBX file.\n        usdz:\n          type: string\n          description: Downloadable URL to the USDZ file.\n        obj:\n          type: string\n          description: Downloadable URL to the OBJ file.\n        mtl:\n          type: string\n          description: Downloadable URL to the MTL file.\n\n    MeshyTaskStatus:\n      type: string\n      description: Status of the task.\n      enum:\n        - PENDING\n        - IN_PROGRESS\n        - SUCCEEDED\n        - FAILED\n        - CANCELED\n\n    MeshyTextureUrls:\n      type: object\n      description: Texture URL object containing PBR maps.\n      properties:\n        base_color:\n          type: string\n          description: Downloadable URL to the base color map image.\n        metallic:\n          type: string\n          description: Downloadable URL to the metallic map image.\n        normal:\n          type: string\n          description: Downloadable URL to the normal map image.\n        roughness:\n          type: string\n          description: Downloadable URL to the roughness map image.\n\n    MeshyTaskError:\n      type: object\n      description: Error object that contains the error message if the task failed.\n      properties:\n        message:\n          type: string\n          description: Detailed error message.\n\n    # Meshy Image to 3D Schemas\n    MeshyImageTo3DRequest:\n      type: object\n      required:\n        - image_url\n      properties:\n        image_url:\n          type: string\n          description: Provide an image for Meshy to use in model creation. Supports .jpg, .jpeg, .png formats or base64-encoded data URI.\n        model_type:\n          type: string\n          description: |\n            Specify the type of 3D mesh generation.\n            - standard: Regular high-detail 3D mesh generation.\n            - lowpoly: Generates low-poly mesh optimized for cleaner polygons.\n            When lowpoly is selected, ai_model, topology, target_polycount, should_remesh, save_pre_remeshed_model are ignored.\n          enum:\n            - standard\n            - lowpoly\n          default: standard\n        ai_model:\n          $ref: \"#/components/schemas/MeshyAiModel\"\n        topology:\n          $ref: \"#/components/schemas/MeshyTopology\"\n        target_polycount:\n          type: integer\n          description: Specify the target number of polygons in the generated model. Valid range is 100 to 300,000.\n          minimum: 100\n          maximum: 300000\n          default: 30000\n        symmetry_mode:\n          $ref: \"#/components/schemas/MeshySymmetryMode\"\n        should_remesh:\n          type: boolean\n          description: Controls whether to enable the remesh phase. When false, returns highest-precision triangular mesh.\n          default: true\n        save_pre_remeshed_model:\n          type: boolean\n          description: When true, stores an extra GLB file before the remesh phase completes. Only takes effect when should_remesh is true.\n          default: false\n        should_texture:\n          type: boolean\n          description: Determines if textures are generated. When false, provides a mesh without textures.\n          default: true\n        enable_pbr:\n          type: boolean\n          description: Generate PBR Maps (metallic, roughness, normal) in addition to the base color.\n          default: false\n        pose_mode:\n          $ref: \"#/components/schemas/MeshyPoseMode\"\n        is_a_t_pose:\n          type: boolean\n          description: Deprecated. Use pose_mode instead. Whether to generate the model in an A/T pose.\n          default: false\n        texture_prompt:\n          type: string\n          description: Provide a text prompt to guide the texturing process. Maximum 600 characters.\n          maxLength: 600\n        texture_image_url:\n          type: string\n          description: Provide a 2d image to guide the texturing process. Supports .jpg, .jpeg, .png formats or base64-encoded data URI.\n        moderation:\n          type: boolean\n          description: When true, input content will be screened for potentially harmful content.\n          default: false\n\n    MeshyImageTo3DCreateResponse:\n      type: object\n      properties:\n        result:\n          type: string\n          description: The task id of the newly created Image to 3D task.\n      required:\n        - result\n\n    MeshyImageTo3DTask:\n      type: object\n      properties:\n        id:\n          type: string\n          description: Unique identifier for the task.\n        type:\n          type: string\n          description: Type of the Image to 3D task.\n          enum:\n            - image-to-3d\n        model_urls:\n          $ref: \"#/components/schemas/MeshyImageTo3DModelUrls\"\n        thumbnail_url:\n          type: string\n          description: Downloadable URL to the thumbnail image of the model file.\n        texture_prompt:\n          type: string\n          description: The text prompt that was used to guide the texturing process.\n        texture_image_url:\n          type: string\n          description: Downloadable URL to the texture image that was used to guide the texturing process.\n        progress:\n          type: integer\n          description: Progress of the task. 0 if not started, 100 when succeeded.\n          minimum: 0\n          maximum: 100\n        started_at:\n          type: integer\n          description: Timestamp of when the task was started, in milliseconds. 0 if not started.\n        created_at:\n          type: integer\n          description: Timestamp of when the task was created, in milliseconds.\n        expires_at:\n          type: integer\n          description: Timestamp of when the task result expires, in milliseconds.\n        finished_at:\n          type: integer\n          description: Timestamp of when the task was finished, in milliseconds. 0 if not finished.\n        status:\n          $ref: \"#/components/schemas/MeshyTaskStatus\"\n        texture_urls:\n          type: array\n          items:\n            $ref: \"#/components/schemas/MeshyTextureUrls\"\n          description: An array of texture URL objects that are generated from the task.\n        preceding_tasks:\n          type: integer\n          description: The count of preceding tasks. Only meaningful when status is PENDING.\n        task_error:\n          $ref: \"#/components/schemas/MeshyTaskError\"\n      required:\n        - id\n        - status\n\n    MeshyImageTo3DModelUrls:\n      type: object\n      description: Downloadable URLs to the 3D model files generated by Meshy.\n      properties:\n        glb:\n          type: string\n          description: Downloadable URL to the GLB file.\n        fbx:\n          type: string\n          description: Downloadable URL to the FBX file.\n        obj:\n          type: string\n          description: Downloadable URL to the OBJ file.\n        usdz:\n          type: string\n          description: Downloadable URL to the USDZ file.\n        mtl:\n          type: string\n          description: Downloadable URL to the MTL file.\n        pre_remeshed_glb:\n          type: string\n          description: Downloadable URL to the original GLB output before remeshing. Available only when should_remesh and save_pre_remeshed_model are both true.\n\n    # Meshy Multi-Image to 3D Schemas\n    MeshyMultiImageTo3DRequest:\n      type: object\n      required:\n        - image_urls\n      properties:\n        image_urls:\n          type: array\n          items:\n            type: string\n          minItems: 1\n          maxItems: 4\n          description: Provide 1 to 4 images for Meshy to use in model creation. All images should depict the same object from different angles.\n        ai_model:\n          type: string\n          description: ID of the model to use.\n          enum:\n            - meshy-5\n            - latest\n          default: latest\n        topology:\n          $ref: \"#/components/schemas/MeshyTopology\"\n        target_polycount:\n          type: integer\n          description: Specify the target number of polygons in the generated model. Valid range is 100 to 300,000.\n          minimum: 100\n          maximum: 300000\n          default: 30000\n        symmetry_mode:\n          $ref: \"#/components/schemas/MeshySymmetryMode\"\n        should_remesh:\n          type: boolean\n          description: Controls whether to enable the remesh phase. When false, returns highest-precision triangular mesh.\n          default: true\n        save_pre_remeshed_model:\n          type: boolean\n          description: When true, stores an extra GLB file before the remesh phase completes. Only takes effect when should_remesh is true.\n          default: false\n        should_texture:\n          type: boolean\n          description: Determines if textures are generated. When false, provides a mesh without textures for 5 credits.\n          default: true\n        enable_pbr:\n          type: boolean\n          description: Generate PBR Maps (metallic, roughness, normal) in addition to the base color.\n          default: false\n        pose_mode:\n          $ref: \"#/components/schemas/MeshyPoseMode\"\n        is_a_t_pose:\n          type: boolean\n          description: Deprecated. Use pose_mode instead. Whether to generate the model in an A/T pose.\n          default: false\n        texture_prompt:\n          type: string\n          description: Provide a text prompt to guide the texturing process. Maximum 600 characters.\n          maxLength: 600\n        texture_image_url:\n          type: string\n          description: Provide a 2d image to guide the texturing process. Supports .jpg, .jpeg, .png formats or base64-encoded data URI.\n        moderation:\n          type: boolean\n          description: When true, input content will be screened for potentially harmful content.\n          default: false\n\n    MeshyMultiImageTo3DCreateResponse:\n      type: object\n      properties:\n        result:\n          type: string\n          description: The task id of the newly created Multi-Image to 3D task.\n      required:\n        - result\n\n    MeshyMultiImageTo3DTask:\n      type: object\n      properties:\n        id:\n          type: string\n          description: Unique identifier for the task.\n        type:\n          type: string\n          description: Type of the Multi-Image to 3D task.\n          enum:\n            - multi-image-to-3d\n        model_urls:\n          $ref: \"#/components/schemas/MeshyImageTo3DModelUrls\"\n        thumbnail_url:\n          type: string\n          description: Downloadable URL to the thumbnail image of the model file.\n        texture_prompt:\n          type: string\n          description: The text prompt that was used to guide the texturing process.\n        progress:\n          type: integer\n          description: Progress of the task. 0 if not started, 100 when succeeded.\n          minimum: 0\n          maximum: 100\n        started_at:\n          type: integer\n          description: Timestamp of when the task was started, in milliseconds. 0 if not started.\n        created_at:\n          type: integer\n          description: Timestamp of when the task was created, in milliseconds.\n        expires_at:\n          type: integer\n          description: Timestamp of when the task result expires, in milliseconds.\n        finished_at:\n          type: integer\n          description: Timestamp of when the task was finished, in milliseconds. 0 if not finished.\n        status:\n          $ref: \"#/components/schemas/MeshyTaskStatus\"\n        texture_urls:\n          type: array\n          items:\n            $ref: \"#/components/schemas/MeshyTextureUrls\"\n          description: An array of texture URL objects that are generated from the task.\n        preceding_tasks:\n          type: integer\n          description: The count of preceding tasks. Only meaningful when status is PENDING.\n        task_error:\n          $ref: \"#/components/schemas/MeshyTaskError\"\n      required:\n        - id\n        - status\n\n    # Meshy Remesh Schemas\n    MeshyRemeshRequest:\n      type: object\n      properties:\n        input_task_id:\n          type: string\n          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.\n        model_url:\n          type: string\n          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.\n        target_formats:\n          type: array\n          items:\n            type: string\n            enum:\n              - glb\n              - fbx\n              - obj\n              - usdz\n              - blend\n              - stl\n          description: A list of target formats for the remeshed model.\n          default:\n            - glb\n        topology:\n          $ref: \"#/components/schemas/MeshyTopology\"\n        target_polycount:\n          type: integer\n          description: Specify the target number of polygons in the generated model. Valid range is 100 to 300,000.\n          minimum: 100\n          maximum: 300000\n          default: 30000\n        resize_height:\n          type: number\n          description: Resize the model to a certain height measured in meters. 0 means no resizing.\n          default: 0\n        origin_at:\n          type: string\n          description: Position of the origin.\n          enum:\n            - bottom\n            - center\n            - \"\"\n        convert_format_only:\n          type: boolean\n          description: If true, only changes the format of the input model file, ignoring other inputs like topology, resize_height, and target_polycount.\n          default: false\n\n    MeshyRemeshCreateResponse:\n      type: object\n      properties:\n        result:\n          type: string\n          description: The id of the newly created remesh task.\n      required:\n        - result\n\n    MeshyRemeshTask:\n      type: object\n      properties:\n        id:\n          type: string\n          description: Unique identifier for the task.\n        type:\n          type: string\n          description: Type of the Remesh task.\n          enum:\n            - remesh\n        model_urls:\n          $ref: \"#/components/schemas/MeshyRemeshModelUrls\"\n        progress:\n          type: integer\n          description: Progress of the task. 0 if not started, 100 when succeeded.\n          minimum: 0\n          maximum: 100\n        status:\n          $ref: \"#/components/schemas/MeshyRemeshTaskStatus\"\n        preceding_tasks:\n          type: integer\n          description: The count of preceding tasks. Only meaningful when status is PENDING.\n        created_at:\n          type: integer\n          description: Timestamp of when the task was created, in milliseconds.\n        started_at:\n          type: integer\n          description: Timestamp of when the task was started, in milliseconds. 0 if not started.\n        finished_at:\n          type: integer\n          description: Timestamp of when the task was finished, in milliseconds. 0 if not finished.\n        task_error:\n          $ref: \"#/components/schemas/MeshyTaskError\"\n      required:\n        - id\n        - status\n\n    MeshyRemeshModelUrls:\n      type: object\n      description: Downloadable URLs to the remeshed 3D model files.\n      properties:\n        glb:\n          type: string\n          description: Downloadable URL to the GLB file.\n        fbx:\n          type: string\n          description: Downloadable URL to the FBX file.\n        obj:\n          type: string\n          description: Downloadable URL to the OBJ file.\n        usdz:\n          type: string\n          description: Downloadable URL to the USDZ file.\n        blend:\n          type: string\n          description: Downloadable URL to the Blender file.\n        stl:\n          type: string\n          description: Downloadable URL to the STL file.\n\n    MeshyRemeshTaskStatus:\n      type: string\n      description: Status of the remesh task.\n      enum:\n        - PENDING\n        - PROCESSING\n        - SUCCEEDED\n        - FAILED\n\n    # Meshy Rigging Schemas\n    MeshyRiggingRequest:\n      type: object\n      properties:\n        input_task_id:\n          type: string\n          description: The input task that needs to be rigged. Required if model_url is not provided.\n        model_url:\n          type: string\n          description: A publicly accessible URL or Data URI to a textured humanoid GLB file. Required if input_task_id is not provided.\n        height_meters:\n          type: number\n          description: The approximate height of the character model in meters. Must be a positive number.\n          default: 1.7\n        texture_image_url:\n          type: string\n          description: The model's UV-unwrapped base color texture image. Publicly accessible URL or Data URI. Supports .png format.\n\n    MeshyRiggingCreateResponse:\n      type: object\n      properties:\n        result:\n          type: string\n          description: The task id of the newly created rigging task.\n      required:\n        - result\n\n    MeshyRiggingTask:\n      type: object\n      properties:\n        id:\n          type: string\n          description: Unique identifier for the task.\n        type:\n          type: string\n          description: Type of the Rigging task.\n          enum:\n            - rig\n        status:\n          $ref: \"#/components/schemas/MeshyTaskStatus\"\n        progress:\n          type: integer\n          description: Progress of the task (0-100). 0 if not started, 100 if succeeded.\n          minimum: 0\n          maximum: 100\n        created_at:\n          type: integer\n          description: Timestamp of when the task was created, in milliseconds.\n        started_at:\n          type: integer\n          description: Timestamp of when the task was started, in milliseconds. 0 if not started.\n        finished_at:\n          type: integer\n          description: Timestamp of when the task was finished, in milliseconds. 0 if not finished.\n        expires_at:\n          type: integer\n          description: Timestamp of when the task result expires, in milliseconds.\n        task_error:\n          $ref: \"#/components/schemas/MeshyTaskError\"\n        result:\n          $ref: \"#/components/schemas/MeshyRiggingResult\"\n        preceding_tasks:\n          type: integer\n          description: The count of preceding tasks. Only meaningful when status is PENDING.\n      required:\n        - id\n        - status\n\n    MeshyRiggingResult:\n      type: object\n      description: Contains the output asset URLs if the task SUCCEEDED.\n      properties:\n        rigged_character_fbx_url:\n          type: string\n          description: Downloadable URL for the rigged character in FBX format.\n        rigged_character_glb_url:\n          type: string\n          description: Downloadable URL for the rigged character in GLB format.\n        basic_animations:\n          $ref: \"#/components/schemas/MeshyRiggingBasicAnimations\"\n\n    MeshyRiggingBasicAnimations:\n      type: object\n      description: Contains URLs for default animations.\n      properties:\n        walking_glb_url:\n          type: string\n          description: Downloadable URL for walking animation in GLB format (with skin).\n        walking_fbx_url:\n          type: string\n          description: Downloadable URL for walking animation in FBX format (with skin).\n        walking_armature_glb_url:\n          type: string\n          description: Downloadable URL for walking animation armature in GLB format.\n        running_glb_url:\n          type: string\n          description: Downloadable URL for running animation in GLB format (with skin).\n        running_fbx_url:\n          type: string\n          description: Downloadable URL for running animation in FBX format (with skin).\n        running_armature_glb_url:\n          type: string\n          description: Downloadable URL for running animation armature in GLB format.\n\n    # Meshy Retexture Schemas\n    MeshyRetextureRequest:\n      type: object\n      properties:\n        input_task_id:\n          type: string\n          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.\n        model_url:\n          type: string\n          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.\n        text_style_prompt:\n          type: string\n          description: Describe your desired texture style of the object using text. Maximum 600 characters. Required if image_style_url is not provided.\n          maxLength: 600\n        image_style_url:\n          type: string\n          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.\n        ai_model:\n          $ref: \"#/components/schemas/MeshyAiModel\"\n        enable_original_uv:\n          type: boolean\n          description: Use the original UV of the model instead of generating new UVs.\n          default: true\n        enable_pbr:\n          type: boolean\n          description: Generate PBR Maps (metallic, roughness, normal) in addition to the base color.\n          default: false\n\n    MeshyRetextureCreateResponse:\n      type: object\n      properties:\n        result:\n          type: string\n          description: The task id of the newly created Retexture task.\n      required:\n        - result\n\n    MeshyRetextureTask:\n      type: object\n      properties:\n        id:\n          type: string\n          description: Unique identifier for the task.\n        type:\n          type: string\n          description: Type of the Retexture task.\n          enum:\n            - retexture\n        model_urls:\n          $ref: \"#/components/schemas/MeshyRetextureModelUrls\"\n        text_style_prompt:\n          type: string\n          description: The text prompt that was used to create the texturing task.\n        image_style_url:\n          type: string\n          description: The image input that was used to create the texturing task.\n        thumbnail_url:\n          type: string\n          description: Downloadable URL to the thumbnail image of the model file.\n        progress:\n          type: integer\n          description: Progress of the task. 0 if not started, 100 when succeeded.\n          minimum: 0\n          maximum: 100\n        started_at:\n          type: integer\n          description: Timestamp of when the task was started, in milliseconds. 0 if not started.\n        created_at:\n          type: integer\n          description: Timestamp of when the task was created, in milliseconds.\n        expires_at:\n          type: integer\n          description: Timestamp of when the task result expires, in milliseconds.\n        finished_at:\n          type: integer\n          description: Timestamp of when the task was finished, in milliseconds. 0 if not finished.\n        status:\n          $ref: \"#/components/schemas/MeshyTaskStatus\"\n        texture_urls:\n          type: array\n          items:\n            $ref: \"#/components/schemas/MeshyTextureUrls\"\n          description: An array of texture URL objects that are generated from the task.\n        preceding_tasks:\n          type: integer\n          description: The count of preceding tasks. Only meaningful when status is PENDING.\n        task_error:\n          $ref: \"#/components/schemas/MeshyTaskError\"\n      required:\n        - id\n        - status\n\n    MeshyRetextureModelUrls:\n      type: object\n      description: Downloadable URLs to the textured 3D model files.\n      properties:\n        glb:\n          type: string\n          description: Downloadable URL to the GLB file.\n        fbx:\n          type: string\n          description: Downloadable URL to the FBX file.\n        usdz:\n          type: string\n          description: Downloadable URL to the USDZ file.\n\n    # Meshy Animation Schemas\n    MeshyAnimationRequest:\n      type: object\n      required:\n        - rig_task_id\n        - action_id\n      properties:\n        rig_task_id:\n          type: string\n          description: The id of a successfully completed rigging task (from POST /openapi/v1/rigging). The character from this task will be animated.\n        action_id:\n          type: integer\n          description: The identifier of the animation action to apply.\n        post_process:\n          $ref: \"#/components/schemas/MeshyAnimationPostProcess\"\n\n    MeshyAnimationPostProcess:\n      type: object\n      description: Parameters for post-processing animation files.\n      required:\n        - operation_type\n      properties:\n        operation_type:\n          type: string\n          description: The type of operation to perform.\n          enum:\n            - change_fps\n            - fbx2usdz\n            - extract_armature\n        fps:\n          type: integer\n          description: The target frame rate. Default is 30. Applicable only when operation_type is change_fps.\n          enum:\n            - 24\n            - 25\n            - 30\n            - 60\n          default: 30\n\n    MeshyAnimationCreateResponse:\n      type: object\n      properties:\n        result:\n          type: string\n          description: The task id of the newly created animation task.\n      required:\n        - result\n\n    MeshyAnimationTask:\n      type: object\n      properties:\n        id:\n          type: string\n          description: Unique identifier for the task.\n        type:\n          type: string\n          description: Type of the Animation task.\n          enum:\n            - animate\n        status:\n          $ref: \"#/components/schemas/MeshyTaskStatus\"\n        progress:\n          type: integer\n          description: Progress of the task (0-100).\n          minimum: 0\n          maximum: 100\n        created_at:\n          type: integer\n          description: Timestamp of when the task was created, in milliseconds.\n        started_at:\n          type: integer\n          description: Timestamp of when the task was started, in milliseconds. 0 if not started.\n        finished_at:\n          type: integer\n          description: Timestamp of when the task was finished, in milliseconds. 0 if not finished.\n        expires_at:\n          type: integer\n          description: Timestamp of when the task result expires, in milliseconds.\n        task_error:\n          $ref: \"#/components/schemas/MeshyTaskError\"\n        result:\n          $ref: \"#/components/schemas/MeshyAnimationResult\"\n        preceding_tasks:\n          type: integer\n          description: The count of preceding tasks. Only meaningful when status is PENDING.\n      required:\n        - id\n        - status\n\n    MeshyAnimationResult:\n      type: object\n      description: Contains the output animation URLs if the task SUCCEEDED.\n      properties:\n        animation_glb_url:\n          type: string\n          description: Downloadable URL for the animation in GLB format.\n        animation_fbx_url:\n          type: string\n          description: Downloadable URL for the animation in FBX format.\n        processed_usdz_url:\n          type: string\n          description: Downloadable URL for the processed animation in USDZ format.\n        processed_armature_fbx_url:\n          type: string\n          description: Downloadable URL for the processed armature in FBX format.\n        processed_animation_fps_fbx_url:\n          type: string\n          description: Downloadable URL for the animation with changed FPS in FBX format.\n\n    XAIImageGenerationRequest:\n      type: object\n      description: Request body for xAI Grok Imagine image generation\n      required:\n        - prompt\n      properties:\n        model:\n          type: string\n          description: Model to be used\n          default: grok-imagine-image\n        n:\n          type: integer\n          description: Number of images to be generated\n          minimum: 1\n          maximum: 10\n          default: 1\n        prompt:\n          type: string\n          description: Prompt for image generation\n        response_format:\n          type: string\n          description: Response format to return the image in. Can be url or b64_json.\n          enum:\n            - url\n            - b64_json\n          default: url\n        aspect_ratio:\n          type: string\n          description: Aspect ratio of the generated image. Defaults to auto for automatically selecting the best ratio for the prompt.\n          enum:\n            - \"1:1\"\n            - \"3:4\"\n            - \"4:3\"\n            - \"9:16\"\n            - \"16:9\"\n            - \"2:3\"\n            - \"3:2\"\n            - \"9:19.5\"\n            - \"19.5:9\"\n            - \"9:20\"\n            - \"20:9\"\n            - \"1:2\"\n            - \"2:1\"\n            - \"auto\"\n          default: \"auto\"\n        resolution:\n          type: string\n          description: Resolution of the generated image. Defaults to 1k.\n          enum:\n            - 1k\n            - 2k\n          default: 1k\n        quality:\n          type: string\n          description: Quality of the output image. Currently a no-op, reserved for future use.\n          enum:\n            - low\n            - medium\n            - high\n        size:\n          type: string\n          description: Size of the image (not supported)\n        style:\n          type: string\n          description: Style of the image (not supported)\n        user:\n          type: string\n          description: A unique identifier representing your end-user, which can help xAI to monitor and detect abuse\n\n    XAIImageEditRequest:\n      type: object\n      description: Request body for xAI Grok Imagine image editing\n      required:\n        - prompt\n      properties:\n        prompt:\n          type: string\n          description: Prompt for image editing\n        image:\n          $ref: \"#/components/schemas/XAIImageObject\"\n        images:\n          type: array\n          description: List of input images for multi-reference editing. Mutually exclusive with image. When multiple images are provided, refer to them as <IMAGE_0>, <IMAGE_1>, etc. in the prompt.\n          items:\n            $ref: \"#/components/schemas/XAIImageObject\"\n        mask:\n          $ref: \"#/components/schemas/XAIImageObject\"\n        model:\n          type: string\n          description: Model to be used\n          default: grok-imagine-image\n        n:\n          type: integer\n          description: Number of image edits to be generated\n        response_format:\n          type: string\n          description: Response format to return the image in. Can be url or b64_json.\n          enum:\n            - url\n            - b64_json\n          default: url\n        resolution:\n          type: string\n          description: Resolution of the generated image. Defaults to 1k.\n          enum:\n            - 1k\n            - 2k\n          default: 1k\n        aspect_ratio:\n          type: string\n          description: Aspect ratio of the output image for image editing with multiple images. For single image editing, do not set this.\n          enum:\n            - \"1:1\"\n            - \"3:4\"\n            - \"4:3\"\n            - \"9:16\"\n            - \"16:9\"\n            - \"2:3\"\n            - \"3:2\"\n            - \"9:19.5\"\n            - \"19.5:9\"\n            - \"9:20\"\n            - \"20:9\"\n            - \"1:2\"\n            - \"2:1\"\n            - \"auto\"\n        quality:\n          type: string\n          description: Quality of the output image. Currently a no-op, reserved for future use.\n          enum:\n            - low\n            - medium\n            - high\n        size:\n          type: string\n          description: Size of the image (not supported)\n        style:\n          type: string\n          description: Style of the image (not supported)\n        user:\n          type: string\n          description: A unique identifier representing your end-user, which can help xAI to monitor and detect abuse\n\n    XAIImageObject:\n      type: object\n      description: Input image object for xAI endpoints\n      required:\n        - url\n      properties:\n        url:\n          type: string\n          description: URL of the input image (public URL or base64-encoded data URI)\n        type:\n          type: string\n          description: Type of the image input\n          enum:\n            - image_url\n\n    XAIImageGenerationResponse:\n      type: object\n      description: Response from xAI image generation or editing\n      properties:\n        data:\n          type: array\n          description: A list of generated image objects\n          items:\n            $ref: \"#/components/schemas/XAIGeneratedImage\"\n        block_reason:\n          type: string\n          description: If the request was blocked by input moderation, contains the block reason\n        usage:\n          $ref: \"#/components/schemas/XAIImageUsage\"\n\n    XAIGeneratedImage:\n      type: object\n      description: A generated image from xAI\n      properties:\n        url:\n          type: string\n          description: A url to the generated image (if response_format is url)\n        b64_json:\n          type: string\n          description: A base64-encoded string representation of the generated image in jpeg encoding (if response_format is b64_json)\n        mime_type:\n          type: string\n          description: The MIME type of the generated image (e.g. image/png, image/jpeg, image/webp).\n    XAIImageUsage:\n      type: object\n      description: Usage information for the image generation request\n      properties:\n        cost_in_usd_ticks:\n          type: integer\n          description: Accurate cost of this request in USD ticks (10,000,000,000 ticks = 1 USD)\n\n    XAIVideoGenerationRequest:\n      type: object\n      description: Request body for xAI Grok Imagine video generation\n      required:\n        - prompt\n      properties:\n        prompt:\n          type: string\n          description: Prompt for video generation\n        model:\n          type: string\n          description: Model to be used\n        image:\n          $ref: \"#/components/schemas/XAIImageObject\"\n        duration:\n          type: integer\n          nullable: true\n          description: Video duration in seconds. Range [1, 15]. Default 8.\n          minimum: 1\n          maximum: 15\n          default: 8\n        aspect_ratio:\n          type: string\n          description: Aspect ratio of the generated video\n          enum:\n            - \"1:1\"\n            - \"16:9\"\n            - \"9:16\"\n            - \"4:3\"\n            - \"3:4\"\n            - \"3:2\"\n            - \"2:3\"\n          default: \"16:9\"\n        resolution:\n          type: string\n          nullable: true\n          description: Resolution of the output video\n        size:\n          type: string\n          nullable: true\n          description: Size of the output video\n        output:\n          type: object\n          nullable: true\n          description: Optional output destination for generated video\n        user:\n          type: string\n          nullable: true\n          description: A unique identifier representing your end-user\n\n    XAIVideoObject:\n      type: object\n      description: Input video object for xAI endpoints\n      required:\n        - url\n      properties:\n        url:\n          type: string\n          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.\n\n    XAIVideoEditRequest:\n      type: object\n      description: Request body for xAI Grok Imagine video editing\n      required:\n        - prompt\n        - video\n      properties:\n        prompt:\n          type: string\n          description: Prompt for video editing\n        video:\n          $ref: \"#/components/schemas/XAIVideoObject\"\n        model:\n          type: string\n          nullable: true\n          description: Model to be used\n        output:\n          type: object\n          nullable: true\n          description: Optional output destination for generated video\n        user:\n          type: string\n          nullable: true\n          description: A unique identifier representing your end-user\n\n    XAIVideoAsyncResponse:\n      type: object\n      description: Response from xAI video generation or editing (async operation)\n      properties:\n        request_id:\n          type: string\n          description: Unique identifier to poll for the completed video\n\n    XAIVideoResultResponse:\n      type: object\n      description: Response from getting video generation result\n      properties:\n        status:\n          type: string\n          description: 'Status of the deferred request: \"pending\" or \"done\"'\n          enum:\n            - pending\n            - done\n        block_reason:\n          type: string\n          nullable: true\n          description: If the request was blocked by input moderation, contains the block reason\n        model:\n          type: string\n          description: The model used to generate the video\n        usage:\n          $ref: \"#/components/schemas/XAIVideoUsage\"\n        video:\n          $ref: \"#/components/schemas/XAIGeneratedVideo\"\n\n    XAIVideoUsage:\n      type: object\n      description: Usage information for the video generation request\n      properties:\n        cost_in_usd_ticks:\n          type: integer\n          description: >\n            The cost of this request expressed in USD ticks.\n            One USD cent equals 100,000,000 ticks, so one US dollar equals 10,000,000,000 ticks.\n\n    XAIGeneratedVideo:\n      type: object\n      description: A generated video from xAI\n      properties:\n        duration:\n          type: integer\n          description: Duration of the generated video in seconds\n        respect_moderation:\n          type: boolean\n          description: Whether the video generated by the model respects moderation rules\n        url:\n          type: string\n          nullable: true\n          description: A url to the generated video\n    RevePostprocessingOperation:\n      type: object\n      description: A postprocessing operation to apply after image generation.\n      required:\n        - process\n      properties:\n        process:\n          type: string\n          description: \"The postprocessing operation: upscale, remove_background, fit_image, or effect.\"\n          enum:\n            - upscale\n            - remove_background\n            - fit_image\n            - effect\n        upscale_factor:\n          type: integer\n          description: Upscale factor (2, 3, or 4). Only used when process is upscale.\n          minimum: 2\n          maximum: 4\n        max_dim:\n          type: integer\n          description: Maximum dimension for fit_image. At least one of max_dim, max_width, or max_height must be set.\n          maximum: 1024\n        max_width:\n          type: integer\n          description: Maximum width for fit_image.\n          maximum: 1024\n        max_height:\n          type: integer\n          description: Maximum height for fit_image.\n          maximum: 1024\n        effect_name:\n          type: string\n          description: Name of the effect to apply. Only used when process is effect.\n        effect_parameters:\n          type: object\n          description: Optional parameters to override default effect settings.\n    ReveImageCreateRequest:\n      type: object\n      description: Request body for Reve image creation.\n      required:\n        - prompt\n      properties:\n        prompt:\n          type: string\n          description: The text description of the desired image. Maximum length is 2560 characters.\n          maxLength: 2560\n        aspect_ratio:\n          type: string\n          description: \"The desired aspect ratio of the generated image.\"\n          enum:\n            - \"16:9\"\n            - \"9:16\"\n            - \"3:2\"\n            - \"2:3\"\n            - \"4:3\"\n            - \"3:4\"\n            - \"1:1\"\n          default: \"3:2\"\n        version:\n          type: string\n          description: \"Model version to use. Supported: latest, reve-create@20250915.\"\n          default: \"latest\"\n        postprocessing:\n          type: array\n          description: Optional postprocessing operations to apply after generation. May add additional cost.\n          items:\n            $ref: \"#/components/schemas/RevePostprocessingOperation\"\n        test_time_scaling:\n          type: number\n          description: If included, the model will spend more effort making better images. Values between 1 and 15 are accepted. Adds additional credits cost.\n          minimum: 1\n          maximum: 15\n    ReveImageEditRequest:\n      type: object\n      description: Request body for Reve image editing.\n      required:\n        - edit_instruction\n        - reference_image\n      properties:\n        edit_instruction:\n          type: string\n          description: The text description of how to edit the provided image. Maximum length is 2560 characters.\n          maxLength: 2560\n        reference_image:\n          type: string\n          description: A base64 encoded image to use as reference for the edit.\n        aspect_ratio:\n          type: string\n          description: \"The desired aspect ratio. Defaults to the aspect ratio of the reference image if not provided.\"\n          enum:\n            - \"16:9\"\n            - \"9:16\"\n            - \"3:2\"\n            - \"2:3\"\n            - \"4:3\"\n            - \"3:4\"\n            - \"1:1\"\n        version:\n          type: string\n          description: \"Model version to use. Supported: latest-fast, latest, reve-edit-fast@20251030, reve-edit@20250915.\"\n          default: \"latest\"\n        postprocessing:\n          type: array\n          description: Optional postprocessing operations to apply after generation. May add additional cost.\n          items:\n            $ref: \"#/components/schemas/RevePostprocessingOperation\"\n        test_time_scaling:\n          type: number\n          description: If included, the model will spend more effort making better images. Values between 1 and 15 are accepted. Adds additional credits cost.\n          minimum: 1\n          maximum: 15\n    ReveImageRemixRequest:\n      type: object\n      description: Request body for Reve image remixing.\n      required:\n        - prompt\n        - reference_images\n      properties:\n        prompt:\n          type: string\n          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.\n          maxLength: 2560\n        reference_images:\n          type: array\n          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.\n          items:\n            type: string\n          minItems: 1\n          maxItems: 6\n        aspect_ratio:\n          type: string\n          description: \"The desired aspect ratio. If not provided, smartly chosen by the model.\"\n          enum:\n            - \"16:9\"\n            - \"9:16\"\n            - \"3:2\"\n            - \"2:3\"\n            - \"4:3\"\n            - \"3:4\"\n            - \"1:1\"\n        version:\n          type: string\n          description: \"Model version to use. Supported: latest-fast, latest, reve-remix-fast@20251030, reve-remix@20250915.\"\n          default: \"latest\"\n        postprocessing:\n          type: array\n          description: Optional postprocessing operations to apply after generation. May add additional cost.\n          items:\n            $ref: \"#/components/schemas/RevePostprocessingOperation\"\n        test_time_scaling:\n          type: number\n          description: If included, the model will spend more effort making better images. Values between 1 and 15 are accepted. Adds additional credits cost.\n          minimum: 1\n          maximum: 15\n    ReveImageResponse:\n      type: object\n      description: Response from the Reve image API.\n      properties:\n        image:\n          type: string\n          description: The base64 encoded image data. Empty if the request was not successful.\n        request_id:\n          type: string\n          description: A unique id for the request.\n        credits_used:\n          type: number\n          description: The number of credits used for this request.\n        credits_remaining:\n          type: number\n          description: The number of credits remaining in your budget.\n        version:\n          type: string\n          description: The specific model version used in the generation process.\n        content_violation:\n          type: boolean\n          description: Indicates whether the generated image violates the content policy.\n    BriaFiboEditRequest:\n      type: object\n      description: Request body for Bria FIBO Edit API\n      required:\n        - images\n      properties:\n        instruction:\n          type: string\n          description: Text-based edit instruction (e.g., \"make the sky blue\", \"add a cat\"). Either instruction or structured_instruction must be provided.\n        images:\n          type: array\n          items:\n            type: string\n          description: The source image to be edited. Publicly available URL or Base64-encoded. Accepted formats JPEG, JPG, PNG, WEBP. Must contain exactly one item.\n          minItems: 1\n          maxItems: 1\n        mask:\n          type: string\n          description: Optional mask image URL or Base64-encoded. Black areas will be preserved, white areas will be edited.\n        structured_instruction:\n          type: string\n          description: A string containing the structured edit instruction in JSON format. Use this instead of instruction for precise, programmatic control.\n        negative_prompt:\n          type: string\n          description: A text prompt specifying concepts, styles, or objects to exclude from the edited image.\n        guidance_scale:\n          type: number\n          format: float\n          description: Determines how closely the generated image should adhere to the instruction.\n          default: 5\n          minimum: 3\n          maximum: 5\n        model_version:\n          type: string\n          description: The version of the model to use.\n          enum:\n            - FIBO\n          default: FIBO\n        steps_num:\n          type: integer\n          description: Number of diffusion steps.\n          default: 50\n          minimum: 20\n          maximum: 50\n        seed:\n          type: integer\n          description: Seed for deterministic generation. If omitted, a random seed is used.\n        ip_signal:\n          type: boolean\n          description: If true, returns a warning for potential IP content in the instruction.\n          default: false\n        prompt_content_moderation:\n          type: boolean\n          description: If true, returns 422 on instruction moderation failure.\n          default: true\n        visual_input_content_moderation:\n          type: boolean\n          description: If true, returns 422 on images or mask moderation failure.\n          default: true\n        visual_output_content_moderation:\n          type: boolean\n          description: If true, returns 422 on visual output moderation failure.\n          default: true\n\n    BriaStructuredInstructionRequest:\n      type: object\n      description: Request body for Bria Structured Instruction Generate API\n      required:\n        - images\n        - instruction\n      properties:\n        instruction:\n          type: string\n          description: Required. Text-based edit instruction (e.g., \"make the sky blue\", \"add a cat\").\n        images:\n          type: array\n          items:\n            type: string\n          description: The source image to be edited. Publicly available URL or Base64-encoded. Must contain exactly one item.\n          minItems: 1\n          maxItems: 1\n        mask:\n          type: string\n          description: Optional mask image URL or Base64-encoded. Black areas will be preserved, white areas will be edited.\n        seed:\n          type: integer\n          description: Seed for deterministic generation. If omitted, a random seed is used.\n        ip_signal:\n          type: boolean\n          description: If true, returns a warning for potential IP content in the instruction.\n          default: false\n        prompt_content_moderation:\n          type: boolean\n          description: If true, returns 422 on instruction moderation failure.\n          default: true\n        visual_input_content_moderation:\n          type: boolean\n          description: If true, returns 422 on images or mask moderation failure.\n          default: true\n\n    BriaAsyncResponse:\n      type: object\n      description: Asynchronous response from Bria API (202 Accepted)\n      properties:\n        request_id:\n          type: string\n          description: Unique identifier for the request.\n        status_url:\n          type: string\n          description: URL to poll for the result.\n        warning:\n          type: string\n          description: Optional warning message.\n\n    BriaErrorResponse:\n      type: object\n      description: Error response from Bria API\n      properties:\n        error:\n          type: object\n          properties:\n            code:\n              type: integer\n              description: Error code.\n            message:\n              type: string\n              description: Error message.\n            details:\n              type: string\n              description: Additional error details.\n        request_id:\n          type: string\n          description: Unique identifier for the request.\n\n    BriaStatusResponse:\n      type: object\n      description: Status response from Bria API\n      properties:\n        status:\n          type: string\n          description: Current status of the request.\n          enum:\n            - IN_PROGRESS\n            - COMPLETED\n            - ERROR\n            - UNKNOWN\n        request_id:\n          type: string\n          description: Unique identifier for the request.\n        result:\n          type: object\n          description: Result object (only present when status is COMPLETED)\n          properties:\n            image_url:\n              type: string\n              description: URL of the generated/edited image.\n            video_url:\n              type: string\n              description: URL of the generated video.\n            seed:\n              type: integer\n              description: Seed used for generation.\n            prompt:\n              type: string\n              description: Original prompt.\n            refined_prompt:\n              type: string\n              description: Refined version of the prompt.\n            structured_prompt:\n              type: string\n              description: The detailed JSON structured prompt.\n        error:\n          type: object\n          description: Error object (only present when status is ERROR)\n          properties:\n            code:\n              type: integer\n              description: Error code.\n            message:\n              type: string\n              description: Error message.\n            details:\n              type: string\n              description: Additional error details.\n\n    BriaVideoRemoveBackgroundRequest:\n      type: object\n      description: Request body for Bria Video Remove Background API\n      required:\n        - video\n      properties:\n        video:\n          type: string\n          description: Publicly accessible URL of the input video. Input resolution supported up to 16000x16000 (16K). Max duration 60 seconds.\n        background_color:\n          type: string\n          description: Background color for the output video. If Transparent, the output codec must support alpha.\n          enum:\n            - Transparent\n            - Black\n            - White\n            - Gray\n            - Red\n            - Green\n            - Blue\n            - Yellow\n            - Cyan\n            - Magenta\n            - Orange\n        output_container_and_codec:\n          type: string\n          description: Output container and codec preset.\n          enum:\n            - mp4_h264\n            - mp4_h265\n            - webm_vp9\n            - mov_h265\n            - mov_proresks\n            - mkv_h264\n            - mkv_h265\n            - mkv_vp9\n            - gif\n        preserve_audio:\n          type: boolean\n          description: Whether to preserve audio from the input video.\n\n    BriaImageRemoveBackgroundRequest:\n      type: object\n      description: Request body for Bria Image Remove Background API\n      required:\n        - image\n      properties:\n        image:\n          type: string\n          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.\n        preserve_alpha:\n          type: boolean\n          description: Controls whether partially transparent areas from the input image are retained in the output after background removal.\n        sync:\n          type: boolean\n          description: When false (default), the request is processed asynchronously. When true, the API holds the connection open until complete.\n        visual_input_content_moderation:\n          type: boolean\n          description: When enabled, applies content moderation to input visual. Returns 422 if the image fails moderation.\n        visual_output_content_moderation:\n          type: boolean\n          description: When enabled, applies content moderation to result visual. Returns 422 if the output fails moderation.\n\n    BriaStatusNotFoundResponse:\n      type: object\n      description: Response when request_id is not found or expired\n      properties:\n        status:\n          type: string\n          enum:\n            - NOT_FOUND\n      required:\n        - status\n\n    WavespeedFlashVSRRequest:\n      type: object\n      description: Request body for WavespeedAI FlashVSR video upscaling\n      properties:\n        video:\n          type: string\n          description: |\n            The video to upscale. Can be a URL to the video file or a base64-encoded video.\n        target_resolution:\n          type: string\n          description: Target resolution to upscale to.\n          enum:\n            - 720p\n            - 1080p\n            - 2k\n            - 4k\n          default: 1080p\n        duration:\n          type: number\n          description: |\n            Duration of the video in seconds\n      required:\n        - video\n        - duration\n\n    WavespeedSeedVR2ImageRequest:\n      type: object\n      description: Request body for WavespeedAI SeedVR2 image upscaling\n      properties:\n        image:\n          type: string\n          description: The URL of the image to upscale.\n        target_resolution:\n          type: string\n          description: The target resolution of the output image.\n          enum:\n            - 2k\n            - 4k\n            - 8k\n          default: 4k\n        output_format:\n          type: string\n          description: The format of the output image.\n          enum:\n            - jpeg\n            - png\n            - webp\n          default: jpeg\n        enable_base64_output:\n          type: boolean\n          description: If enabled, the output will be encoded into a BASE64 string instead of a URL.\n          default: false\n      required:\n        - image\n\n    WavespeedTaskResponse:\n      type: object\n      description: Response from WavespeedAI task submission\n      properties:\n        code:\n          type: integer\n          description: HTTP status code (e.g., 200 for success)\n        message:\n          type: string\n          description: Status message (e.g., \"success\")\n        data:\n          type: object\n          properties:\n            id:\n              type: string\n              description: Unique identifier for the prediction/task\n            model:\n              type: string\n              description: Model ID used for the prediction\n            outputs:\n              type: array\n              items:\n                type: string\n              description: Array of URLs to the generated content (empty when status is not completed)\n            urls:\n              type: object\n              properties:\n                get:\n                  type: string\n                  description: URL to retrieve the prediction result\n            has_nsfw_contents:\n              type: array\n              items:\n                type: boolean\n              description: Array of boolean values indicating NSFW detection for each output\n            status:\n              type: string\n              description: Status of the task\n              enum:\n                - created\n                - processing\n                - completed\n                - failed\n            created_at:\n              type: string\n              description: ISO timestamp of when the request was created\n            error:\n              type: string\n              description: Error message (empty if no error occurred)\n            timings:\n              type: object\n              properties:\n                inference:\n                  type: integer\n                  description: Inference time in milliseconds\n\n    WavespeedTaskResultResponse:\n      type: object\n      description: Response from WavespeedAI task result query\n      properties:\n        code:\n          type: integer\n          description: HTTP status code (e.g., 200 for success)\n        message:\n          type: string\n          description: Status message (e.g., \"success\")\n        data:\n          type: object\n          properties:\n            id:\n              type: string\n              description: Unique identifier for the prediction/task\n            model:\n              type: string\n              description: Model ID used for the prediction\n            outputs:\n              type: array\n              items:\n                type: string\n              description: Array of URLs to the generated content (empty when status is not completed)\n            urls:\n              type: object\n              properties:\n                get:\n                  type: string\n                  description: URL to retrieve the prediction result\n            status:\n              type: string\n              description: Status of the task\n              enum:\n                - created\n                - processing\n                - completed\n                - failed\n            created_at:\n              type: string\n              description: ISO timestamp of when the request was created\n            error:\n              type: string\n              description: Error message (empty if no error occurred)\n            timings:\n              type: object\n              properties:\n                inference:\n                  type: integer\n                  description: Inference time in milliseconds\n  parameters:\n    PixverseAiTraceId:\n      name: Ai-trace-id\n      in: header\n      required: true\n      schema:\n        type: string\n      description: Unique UUID for each request.\n\n  securitySchemes:\n    BearerAuth:\n      type: http\n      scheme: bearer\n      bearerFormat: JWT\n"
  },
  {
    "path": "comfy_cli/command/generate/spec.py",
    "content": "\"\"\"Load the bundled openapi.yml and expose the curated image-endpoint registry.\n\nLookup order on disk:\n1. ``~/.comfy/openapi-cache.yml`` if fresher than CACHE_TTL_DAYS\n2. The vendored copy under ``comfy_cli/command/generate/spec/openapi.yml``\n\nThe parsed spec is cached in-process via functools.lru_cache so repeated lookups\ninside a single CLI invocation don't re-parse the 30k-line YAML.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport re as _re\nimport time\nfrom dataclasses import dataclass\nfrom functools import lru_cache\nfrom pathlib import Path\nfrom typing import Any\n\nimport yaml\n\n\nclass _YamlLoader(yaml.SafeLoader):\n    \"\"\"SafeLoader that strips YAML 1.1's bool aliases for ``on``/``off``/\n    ``yes``/``no``/``y``/``n``.\n\n    The vendored openapi uses unquoted ``[on, off]`` and similar as **string**\n    enum values (e.g. Kling's ``sound`` field), but PyYAML's default resolvers\n    promote them to ``True``/``False`` — which then breaks our flag-rendering\n    (`'|'.join` on a list with booleans) and the upstream API contract. Limit\n    bool resolution to the YAML 1.2 spelling (``true``/``false`` only).\"\"\"\n\n\n_YamlLoader.yaml_implicit_resolvers = {\n    k: [(t, r) for (t, r) in resolvers if t != \"tag:yaml.org,2002:bool\"]\n    for k, resolvers in yaml.SafeLoader.yaml_implicit_resolvers.items()\n}\n_YamlLoader.add_implicit_resolver(\n    \"tag:yaml.org,2002:bool\",\n    _re.compile(r\"^(?:true|True|TRUE|false|False|FALSE)$\"),\n    list(\"tTfF\"),\n)\n\nPROXY_PREFIX = \"/proxy/\"\nDEFAULT_BASE_URL = \"https://api.comfy.org\"\nCACHE_TTL_SECONDS = 7 * 24 * 60 * 60\n\n_BUNDLED_SPEC = Path(__file__).parent / \"spec\" / \"openapi.yml\"\n_USER_CACHE = Path(os.path.expanduser(\"~/.comfy/openapi-cache.yml\"))\n\n\n@dataclass(frozen=True)\nclass Endpoint:\n    \"\"\"A single curated cloud API endpoint, resolved against the openapi spec.\"\"\"\n\n    id: str  # path with /proxy/ stripped, e.g. \"openai/images/generations\"\n    path: str  # full openapi path, e.g. \"/proxy/openai/images/generations\"\n    method: str  # \"post\" / \"get\"\n    partner: str  # first path segment under /proxy/\n    summary: str\n    category: str  # \"text-to-image\", \"image-edit\", \"upscale\", \"inpaint\", ...\n    request_schema: dict[str, Any]  # resolved (no $ref) request body schema\n    request_content_type: str  # \"application/json\" | \"multipart/form-data\"\n    response_schema: dict[str, Any]  # resolved 200 response schema\n    polling: str | None  # \"bfl\" | \"kling\" | \"luma\" | \"topaz\" | None\n\n\n# Short, creative-facing aliases mapping to the curated openapi paths below.\n# Aliases are what end users actually type: `comfy generate flux-pro --prompt …`.\n# The full openapi path remains accepted as a power-user escape hatch.\n_ALIASES: dict[str, str] = {\n    # Flux / BFL\n    \"flux-pro\": \"bfl/flux-pro-1.1/generate\",\n    \"flux-ultra\": \"bfl/flux-pro-1.1-ultra/generate\",\n    \"flux-2\": \"bfl/flux-2-pro/generate\",\n    \"flux-kontext\": \"bfl/flux-kontext-pro/generate\",\n    \"flux-kontext-max\": \"bfl/flux-kontext-max/generate\",\n    \"flux-fill\": \"bfl/flux-pro-1.0-fill/generate\",\n    \"flux-expand\": \"bfl/flux-pro-1.0-expand/generate\",\n    \"flux-canny\": \"bfl/flux-pro-1.0-canny/generate\",\n    \"flux-depth\": \"bfl/flux-pro-1.0-depth/generate\",\n    # Ideogram\n    \"ideogram\": \"ideogram/ideogram-v3/generate\",\n    \"ideogram-edit\": \"ideogram/ideogram-v3/edit\",\n    \"ideogram-remix\": \"ideogram/ideogram-v3/remix\",\n    \"ideogram-reframe\": \"ideogram/ideogram-v3/reframe\",\n    \"ideogram-bg\": \"ideogram/ideogram-v3/replace-background\",\n    # Stability\n    \"stability-ultra\": \"stability/v2beta/stable-image/generate/ultra\",\n    \"stability-sd3\": \"stability/v2beta/stable-image/generate/sd3\",\n    \"stability-upscale\": \"stability/v2beta/stable-image/upscale/conservative\",\n    \"stability-upscale-creative\": \"stability/v2beta/stable-image/upscale/creative\",\n    \"stability-upscale-fast\": \"stability/v2beta/stable-image/upscale/fast\",\n    # Recraft\n    \"recraft\": \"recraft/image_generation\",\n    \"recraft-vectorize\": \"recraft/images/vectorize\",\n    \"recraft-upscale\": \"recraft/images/crispUpscale\",\n    \"recraft-upscale-creative\": \"recraft/images/creativeUpscale\",\n    \"recraft-rmbg\": \"recraft/images/removeBackground\",\n    \"recraft-replace-bg\": \"recraft/images/replaceBackground\",\n    \"recraft-i2i\": \"recraft/images/imageToImage\",\n    \"recraft-inpaint\": \"recraft/images/inpaint\",\n    # OpenAI / DALL·E\n    \"dalle\": \"openai/images/generations\",\n    \"dalle-edit\": \"openai/images/edits\",\n    # xAI / Grok\n    \"grok\": \"xai/v1/images/generations\",\n    \"grok-edit\": \"xai/v1/images/edits\",\n    # Reve\n    \"reve\": \"reve/v1/image/create\",\n    \"reve-edit\": \"reve/v1/image/edit\",\n    # Runway\n    \"runway\": \"runway/text_to_image\",\n    # Video — Kling\n    \"kling\": \"kling/v1/videos/text2video\",\n    \"kling-i2v\": \"kling/v1/videos/image2video\",\n    \"kling-extend\": \"kling/v1/videos/video-extend\",\n    \"kling-lipsync\": \"kling/v1/videos/lip-sync\",\n    # Video — Luma Dream Machine\n    \"luma\": \"luma/generations\",\n    \"luma-i2v\": \"luma/generations/image\",\n    # Video — MiniMax / Hailuo\n    \"hailuo\": \"minimax/video_generation\",\n    # Video — Runway Gen-3\n    \"runway-i2v\": \"runway/image_to_video\",\n    # Video — Moonvalley\n    \"moonvalley-t2v\": \"moonvalley/prompts/text-to-video\",\n    \"moonvalley-i2v\": \"moonvalley/prompts/image-to-video\",\n    # Video — Pika\n    \"pika\": \"pika/generate/2.2/t2v\",\n    \"pika-i2v\": \"pika/generate/2.2/i2v\",\n    # Video — Vidu\n    \"vidu\": \"vidu/text2video\",\n    \"vidu-i2v\": \"vidu/img2video\",\n    \"vidu-extend\": \"vidu/extend\",\n    # Video — xAI Grok\n    \"grok-video\": \"xai/v1/videos/generations\",\n    # Google Gemini Flash Image (nano-banana). The model variant lives in the\n    # URL path; the adapter substitutes ``--model`` at send time.\n    \"nano-banana\": \"vertexai/gemini/{model}\",\n    # ByteDance Seedance (video).\n    \"seedance\": \"byteplus/api/v3/contents/generations/tasks\",\n}\n\n\n# Used in the `list` table for endpoints whose openapi summary is empty or too\n# generic to convey what the model is.\n_SUMMARY_OVERRIDES: dict[str, str] = {\n    \"vertexai/gemini/{model}\": (\n        \"Google Gemini Flash Image (nano-banana) — text-to-image and image edits \"\n        \"from a prompt plus optional reference images.\"\n    ),\n    \"byteplus/api/v3/contents/generations/tasks\": (\n        \"ByteDance Seedance — text-to-video and image-to-video (3–12s clips, up to 1080p).\"\n    ),\n}\n\n_PREFERRED_ALIAS: dict[str, str] = {v: k for k, v in _ALIASES.items()}\n\n\ndef aliases() -> dict[str, str]:\n    \"\"\"Return a copy of the alias → endpoint-id map (used for `list`).\"\"\"\n    return dict(_ALIASES)\n\n\ndef preferred_alias(endpoint_id: str) -> str | None:\n    \"\"\"Return the short alias for an endpoint id, if any.\"\"\"\n    return _PREFERRED_ALIAS.get(endpoint_id)\n\n\ndef resolve_alias(target: str) -> str:\n    \"\"\"Map a user-typed model name to the canonical endpoint id.\n    Accepts an alias, an endpoint id, or the full /proxy/... path.\"\"\"\n    if target in _ALIASES:\n        return _ALIASES[target]\n    if target.startswith(PROXY_PREFIX):\n        return target[len(PROXY_PREFIX) :]\n    return target\n\n\n# Curated endpoint allowlist. Tuples of (endpoint_id, category, polling).\n# ``polling`` is the partner-key the poll registry uses (None = sync).\n# Endpoint id is the openapi path with /proxy/ stripped.\n_ENDPOINT_ALLOWLIST: list[tuple[str, str, str | None]] = [\n    # OpenAI\n    (\"openai/images/generations\", \"text-to-image\", None),\n    (\"openai/images/edits\", \"image-edit\", None),\n    # BFL / Flux — all async via polling_url\n    (\"bfl/flux-pro-1.1/generate\", \"text-to-image\", \"bfl\"),\n    (\"bfl/flux-pro-1.1-ultra/generate\", \"text-to-image\", \"bfl\"),\n    (\"bfl/flux-kontext-pro/generate\", \"image-edit\", \"bfl\"),\n    (\"bfl/flux-kontext-max/generate\", \"image-edit\", \"bfl\"),\n    (\"bfl/flux-2-pro/generate\", \"text-to-image\", \"bfl\"),\n    (\"bfl/flux-pro-1.0-fill/generate\", \"inpaint\", \"bfl\"),\n    (\"bfl/flux-pro-1.0-expand/generate\", \"outpaint\", \"bfl\"),\n    (\"bfl/flux-pro-1.0-canny/generate\", \"controlnet\", \"bfl\"),\n    (\"bfl/flux-pro-1.0-depth/generate\", \"controlnet\", \"bfl\"),\n    # Ideogram\n    (\"ideogram/ideogram-v3/generate\", \"text-to-image\", None),\n    (\"ideogram/ideogram-v3/edit\", \"image-edit\", None),\n    (\"ideogram/ideogram-v3/remix\", \"image-edit\", None),\n    (\"ideogram/ideogram-v3/reframe\", \"image-edit\", None),\n    (\"ideogram/ideogram-v3/replace-background\", \"image-edit\", None),\n    # Stability\n    (\"stability/v2beta/stable-image/generate/ultra\", \"text-to-image\", None),\n    (\"stability/v2beta/stable-image/generate/sd3\", \"text-to-image\", None),\n    (\"stability/v2beta/stable-image/upscale/conservative\", \"upscale\", None),\n    (\"stability/v2beta/stable-image/upscale/creative\", \"upscale\", None),\n    (\"stability/v2beta/stable-image/upscale/fast\", \"upscale\", None),\n    # Recraft\n    (\"recraft/image_generation\", \"text-to-image\", None),\n    (\"recraft/images/vectorize\", \"vectorize\", None),\n    (\"recraft/images/crispUpscale\", \"upscale\", None),\n    (\"recraft/images/removeBackground\", \"background\", None),\n    (\"recraft/images/imageToImage\", \"image-to-image\", None),\n    (\"recraft/images/inpaint\", \"inpaint\", None),\n    (\"recraft/images/replaceBackground\", \"background\", None),\n    (\"recraft/images/creativeUpscale\", \"upscale\", None),\n    # xAI\n    (\"xai/v1/images/generations\", \"text-to-image\", None),\n    (\"xai/v1/images/edits\", \"image-edit\", None),\n    # Reve\n    (\"reve/v1/image/create\", \"text-to-image\", None),\n    (\"reve/v1/image/edit\", \"image-edit\", None),\n    # Runway (image)\n    (\"runway/text_to_image\", \"text-to-image\", None),\n    # Video — Kling\n    (\"kling/v1/videos/text2video\", \"text-to-video\", \"kling\"),\n    (\"kling/v1/videos/image2video\", \"image-to-video\", \"kling\"),\n    (\"kling/v1/videos/video-extend\", \"video-extend\", \"kling\"),\n    (\"kling/v1/videos/lip-sync\", \"lipsync\", \"kling\"),\n    # Video — Luma\n    (\"luma/generations\", \"text-to-video\", \"luma\"),\n    (\"luma/generations/image\", \"image-to-video\", \"luma\"),\n    # Video — MiniMax / Hailuo\n    (\"minimax/video_generation\", \"text-to-video\", \"minimax\"),\n    # Video — Runway\n    (\"runway/image_to_video\", \"image-to-video\", \"runway\"),\n    # Video — Moonvalley\n    (\"moonvalley/prompts/text-to-video\", \"text-to-video\", \"moonvalley\"),\n    (\"moonvalley/prompts/image-to-video\", \"image-to-video\", \"moonvalley\"),\n    # Video — Pika\n    (\"pika/generate/2.2/t2v\", \"text-to-video\", \"pika\"),\n    (\"pika/generate/2.2/i2v\", \"image-to-video\", \"pika\"),\n    # Video — Vidu\n    (\"vidu/text2video\", \"text-to-video\", \"vidu\"),\n    (\"vidu/img2video\", \"image-to-video\", \"vidu\"),\n    (\"vidu/extend\", \"video-extend\", \"vidu\"),\n    # Video — xAI Grok\n    (\"xai/v1/videos/generations\", \"text-to-video\", \"xai_video\"),\n    # Google Gemini Flash Image (nano-banana). Sync; adapter decodes inline data.\n    (\"vertexai/gemini/{model}\", \"image-edit\", None),\n    # ByteDance Seedance (video) — async, custom poller.\n    (\"byteplus/api/v3/contents/generations/tasks\", \"text-to-video\", \"seedance\"),\n]\n\n\nclass SpecError(RuntimeError):\n    pass\n\n\ndef _select_spec_path() -> Path:\n    if _USER_CACHE.is_file():\n        age = time.time() - _USER_CACHE.stat().st_mtime\n        if age < CACHE_TTL_SECONDS:\n            return _USER_CACHE\n    if not _BUNDLED_SPEC.is_file():\n        raise SpecError(f\"openapi.yml not found at {_BUNDLED_SPEC}\")\n    return _BUNDLED_SPEC\n\n\n@lru_cache(maxsize=1)\ndef load_raw_spec() -> dict[str, Any]:\n    path = _select_spec_path()\n    with path.open(\"r\", encoding=\"utf-8\") as f:\n        return yaml.load(f, Loader=_YamlLoader)\n\n\ndef base_url() -> str:\n    override = os.environ.get(\"COMFY_API_BASE_URL\")\n    if override:\n        return override.rstrip(\"/\")\n    spec = load_raw_spec()\n    servers = spec.get(\"servers\") or [{\"url\": DEFAULT_BASE_URL}]\n    return str(servers[0][\"url\"]).rstrip(\"/\")\n\n\ndef _resolve_ref(spec: dict[str, Any], ref: str) -> dict[str, Any]:\n    if not ref.startswith(\"#/\"):\n        raise SpecError(f\"Only local $refs are supported: {ref}\")\n    parts = ref[2:].split(\"/\")\n    node: Any = spec\n    for p in parts:\n        node = node[p]\n    return node\n\n\ndef _resolve(spec: dict[str, Any], node: Any, seen: frozenset[str] = frozenset()) -> Any:\n    \"\"\"Recursively inline $refs in a schema. Cycles are broken with a placeholder.\"\"\"\n    if isinstance(node, dict):\n        if \"$ref\" in node:\n            ref = node[\"$ref\"]\n            if ref in seen:\n                return {\"type\": \"object\", \"x-recursive-ref\": ref}\n            resolved = _resolve_ref(spec, ref)\n            return _resolve(spec, resolved, seen | {ref})\n        return {k: _resolve(spec, v, seen) for k, v in node.items()}\n    if isinstance(node, list):\n        return [_resolve(spec, item, seen) for item in node]\n    return node\n\n\ndef _detect_polling(partner: str, response_schema: dict[str, Any]) -> str | None:\n    \"\"\"Heuristic: classify async polling style by partner + response shape.\"\"\"\n    props = response_schema.get(\"properties\", {}) if isinstance(response_schema, dict) else {}\n    if partner == \"bfl\" and \"polling_url\" in props:\n        return \"bfl\"\n    if partner == \"kling\" and \"data\" in props:\n        return \"kling\"\n    if partner == \"luma\" and (\"state\" in props or \"id\" in props):\n        return \"luma\"\n    if partner == \"topaz\" and \"process_id\" in props:\n        return \"topaz\"\n    return None\n\n\n@lru_cache(maxsize=1)\ndef _registry() -> dict[str, Endpoint]:\n    spec = load_raw_spec()\n    paths = spec.get(\"paths\") or {}\n    registry: dict[str, Endpoint] = {}\n    for endpoint_id, category, polling_hint in _ENDPOINT_ALLOWLIST:\n        path = PROXY_PREFIX + endpoint_id\n        node = paths.get(path)\n        if not node:\n            continue  # spec drift — skip silently, surfaced via `comfy api models`\n        # All image endpoints are POST; pick the first defined method anyway.\n        method = \"post\" if \"post\" in node else next(iter(node.keys()))\n        op = node[method]\n        partner = endpoint_id.split(\"/\", 1)[0]\n\n        req_body = op.get(\"requestBody\") or {}\n        content = req_body.get(\"content\") or {}\n        if \"application/json\" in content:\n            ctype = \"application/json\"\n        elif \"multipart/form-data\" in content:\n            ctype = \"multipart/form-data\"\n        else:\n            ctype = next(iter(content.keys()), \"application/json\")\n        req_schema = _resolve(spec, (content.get(ctype) or {}).get(\"schema\") or {})\n\n        # 200 response\n        resp = (op.get(\"responses\") or {}).get(\"200\") or {}\n        resp_content = resp.get(\"content\") or {}\n        resp_ctype = \"application/json\" if \"application/json\" in resp_content else next(iter(resp_content), \"\")\n        resp_schema = _resolve(spec, (resp_content.get(resp_ctype) or {}).get(\"schema\") or {}) if resp_ctype else {}\n\n        polling = polling_hint or _detect_polling(partner, resp_schema)\n\n        registry[endpoint_id] = Endpoint(\n            id=endpoint_id,\n            path=path,\n            method=method,\n            partner=partner,\n            summary=_SUMMARY_OVERRIDES.get(endpoint_id)\n            or str(op.get(\"summary\") or op.get(\"description\") or \"\").strip(),\n            category=category,\n            request_schema=req_schema if isinstance(req_schema, dict) else {},\n            request_content_type=ctype,\n            response_schema=resp_schema if isinstance(resp_schema, dict) else {},\n            polling=polling,\n        )\n    return registry\n\n\ndef list_endpoints(\n    partner: str | None = None,\n    category: str | None = None,\n    query: str | None = None,\n) -> list[Endpoint]:\n    out = list(_registry().values())\n    if partner:\n        out = [e for e in out if e.partner == partner.lower()]\n    if category:\n        out = [e for e in out if e.category == category]\n    if query:\n        q = query.lower()\n        out = [e for e in out if q in e.id.lower() or q in e.summary.lower()]\n    out.sort(key=lambda e: (e.partner, e.id))\n    return out\n\n\ndef get_endpoint(endpoint_id: str) -> Endpoint:\n    reg = _registry()\n    canonical = resolve_alias(endpoint_id)\n    if canonical in reg:\n        return reg[canonical]\n    raise SpecError(_unknown_endpoint_message(endpoint_id))\n\n\ndef _unknown_endpoint_message(endpoint_id: str) -> str:\n    \"\"\"Build a helpful error suggesting close matches.\"\"\"\n    import difflib\n\n    candidates = list(_registry().keys()) + list(_ALIASES.keys())\n    close = difflib.get_close_matches(endpoint_id, candidates, n=3, cutoff=0.5)\n    msg = f\"Unknown model: {endpoint_id!r}.\"\n    if close:\n        msg += \"\\nDid you mean: \" + \", \".join(close) + \"?\"\n    msg += \"\\nRun `comfy generate list` to see available models.\"\n    return msg\n\n\ndef write_cache(yaml_text: str) -> Path:\n    \"\"\"Write `yaml_text` to the user cache, ensuring the parent dir exists.\"\"\"\n    _USER_CACHE.parent.mkdir(parents=True, exist_ok=True)\n    _USER_CACHE.write_text(yaml_text, encoding=\"utf-8\")\n    # Invalidate in-process cache so the next load picks it up.\n    load_raw_spec.cache_clear()\n    _registry.cache_clear()\n    return _USER_CACHE\n\n\ndef active_spec_path() -> Path:\n    return _select_spec_path()\n"
  },
  {
    "path": "comfy_cli/command/generate/upload.py",
    "content": "\"\"\"Upload reference assets via ``/customers/storage``.\n\nThe cloud endpoint issues short-lived signed URLs:\n\n1. POST ``/customers/storage`` with ``{file_name, content_type, file_hash?}`` →\n   ``{upload_url, download_url, expires_at, existing_file}``.\n2. If ``existing_file`` is true the server already has a hash-match — skip the\n   PUT and reuse ``download_url``. Otherwise PUT the bytes to ``upload_url``\n   with the same ``Content-Type`` header.\n3. ``download_url`` is what downstream model calls reference; it's a signed URL\n   that expires after 24 hours.\n\nThis module also exposes a small helper that takes either a local path or a\nremote ``http(s)://`` URL — remote URLs are re-hosted by downloading and then\nrunning the same flow.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport hashlib\nimport mimetypes\nfrom dataclasses import dataclass\nfrom pathlib import Path\n\nimport httpx\n\nfrom comfy_cli.command.generate import client, spec\n\n_DEFAULT_CONTENT_TYPE = \"application/octet-stream\"\n\n\n@dataclass(frozen=True)\nclass UploadResult:\n    url: str  # the signed download_url to feed into downstream model calls\n    expires_at: str | None  # ISO 8601 timestamp from the server\n    existing_file: bool  # True when the server returned a hash-match (no upload)\n\n\ndef _guess_content_type(name: str) -> str:\n    ctype, _ = mimetypes.guess_type(name)\n    return ctype or _DEFAULT_CONTENT_TYPE\n\n\ndef _sha256_hex(data: bytes) -> str:\n    return hashlib.sha256(data).hexdigest()\n\n\ndef _request_signed_url(\n    file_name: str,\n    content_type: str,\n    file_hash: str,\n    api_key: str,\n) -> dict:\n    \"\"\"POST /customers/storage and return the parsed response dict.\"\"\"\n    url = spec.base_url() + \"/customers/storage\"\n    body = {\"file_name\": file_name, \"content_type\": content_type, \"file_hash\": file_hash}\n    headers = client._auth_headers(api_key, {\"Content-Type\": \"application/json\"})\n    resp = httpx.post(url, json=body, headers=headers, timeout=30.0)\n    client.raise_for_status(resp)\n    try:\n        return resp.json()\n    except ValueError as e:\n        # Surface the same way other parse errors do — bare ValueError would\n        # leak a traceback into CLI output.\n        raise client.ApiError(resp.status_code, resp.text or str(e), \"Storage response was not valid JSON\") from e\n\n\ndef _put_bytes(upload_url: str, data: bytes, content_type: str) -> None:\n    \"\"\"PUT the raw bytes to the signed URL. No auth header — the URL is signed.\"\"\"\n    with httpx.Client(timeout=120.0, follow_redirects=False) as c:\n        r = c.put(upload_url, content=data, headers={\"Content-Type\": content_type})\n        if r.status_code >= 400:\n            raise client.ApiError(r.status_code, r.text, f\"Upload to signed URL failed (HTTP {r.status_code})\")\n\n\ndef upload_bytes(data: bytes, file_name: str, api_key: str, content_type: str | None = None) -> UploadResult:\n    \"\"\"Upload raw bytes and return the signed download URL. Hash-based dedup is\n    handled transparently — if the server already has these bytes, ``existing_file``\n    is True and no PUT happens.\"\"\"\n    ctype = content_type or _guess_content_type(file_name)\n    file_hash = _sha256_hex(data)\n    signed = _request_signed_url(file_name=file_name, content_type=ctype, file_hash=file_hash, api_key=api_key)\n    if not signed.get(\"existing_file\"):\n        upload_url = signed.get(\"upload_url\")\n        if not upload_url:\n            raise client.ApiError(0, str(signed), \"Server response missing upload_url\")\n        _put_bytes(upload_url, data, ctype)\n    download_url = signed.get(\"download_url\")\n    if not download_url:\n        raise client.ApiError(0, str(signed), \"Server response missing download_url\")\n    return UploadResult(\n        url=download_url,\n        expires_at=signed.get(\"expires_at\"),\n        existing_file=bool(signed.get(\"existing_file\", False)),\n    )\n\n\ndef upload_path(path: Path | str, api_key: str) -> UploadResult:\n    p = Path(path).expanduser()\n    if not p.is_file():\n        raise client.ApiError(0, \"\", f\"File not found: {p}\")\n    try:\n        data = p.read_bytes()\n    except OSError as e:\n        raise client.ApiError(0, \"\", f\"Unable to read file: {p} ({e})\") from e\n    return upload_bytes(data, file_name=p.name, api_key=api_key)\n\n\ndef upload_remote_url(url: str, api_key: str) -> UploadResult:\n    \"\"\"Re-host a remote http(s) URL through /customers/storage so it ends up on\n    Comfy's CDN. Mirrors the genmedia behavior of accepting URLs to `upload`.\"\"\"\n    with httpx.Client(timeout=60.0, follow_redirects=True) as c:\n        r = c.get(url)\n        r.raise_for_status()\n        data = r.content\n        # Prefer the server's Content-Type; fall back to URL extension.\n        ctype = r.headers.get(\"content-type\", \"\").split(\";\", 1)[0].strip() or _guess_content_type(url)\n        # Pick a filename from the URL path, defaulting to a hash-based name.\n        suffix = Path(url.split(\"?\", 1)[0]).name or _sha256_hex(data)[:12]\n    return upload_bytes(data, file_name=suffix, api_key=api_key, content_type=ctype)\n\n\ndef upload_target(target: str | Path, api_key: str) -> UploadResult:\n    \"\"\"Accept either a local file path or a remote URL; return the hosted URL.\"\"\"\n    s = str(target)\n    if s.startswith((\"http://\", \"https://\")):\n        return upload_remote_url(s, api_key=api_key)\n    return upload_path(s, api_key=api_key)\n"
  },
  {
    "path": "comfy_cli/command/github/pr_info.py",
    "content": "from typing import NamedTuple\n\n\nclass PRInfo(NamedTuple):\n    number: int\n    head_repo_url: str\n    head_branch: str\n    base_repo_url: str\n    base_branch: str\n    title: str\n    user: str\n    mergeable: bool\n\n    @property\n    def is_fork(self) -> bool:\n        return self.head_repo_url != self.base_repo_url\n"
  },
  {
    "path": "comfy_cli/command/install.py",
    "content": "import os\nimport platform\nimport re\nimport subprocess\nimport sys\nfrom typing import TypedDict\nfrom urllib.parse import urlparse\n\nimport git\nimport requests\nimport semver\nimport typer\nfrom rich import print as rprint\nfrom rich.console import Console\nfrom rich.panel import Panel\nfrom rich.prompt import Confirm\n\nfrom comfy_cli import constants, ui\nfrom comfy_cli.command.custom_nodes.command import update_node_id_cache\nfrom comfy_cli.command.github.pr_info import PRInfo\nfrom comfy_cli.constants import GPU_OPTION\nfrom comfy_cli.cuda_detect import DEFAULT_CUDA_TAG\nfrom comfy_cli.git_utils import checkout_pr, git_checkout_tag\nfrom comfy_cli.resolve_python import ensure_workspace_python\nfrom comfy_cli.uv import DependencyCompiler\nfrom comfy_cli.workspace_manager import WorkspaceManager, check_comfy_repo\n\nworkspace_manager = WorkspaceManager()\nconsole = Console()\n\n\ndef get_os_details():\n    os_name = platform.system()  # e.g., Linux, Darwin (macOS), Windows\n    os_version = platform.release()\n    return os_name, os_version\n\n\ndef _pip_install_torch(python: str, index_args: list[str]) -> subprocess.CompletedProcess:\n    \"\"\"Install torch, torchvision, and torchaudio with the given index arguments.\"\"\"\n    return subprocess.run(\n        [python, \"-m\", \"pip\", \"install\", \"torch\", \"torchvision\", \"torchaudio\"] + index_args,\n        check=False,\n    )\n\n\ndef pip_install_comfyui_dependencies(\n    repo_dir,\n    gpu: GPU_OPTION,\n    plat: constants.OS,\n    cuda_version: constants.CUDAVersion | None,\n    skip_torch_or_directml: bool,\n    skip_requirement: bool,\n    python: str = sys.executable,\n    rocm_version: constants.ROCmVersion = constants.ROCmVersion.v6_3,\n    cuda_tag: str | None = None,\n):\n    os.chdir(repo_dir)\n\n    result = None\n    if not skip_torch_or_directml:\n        # install torch for AMD Linux\n        if gpu == GPU_OPTION.AMD and plat == constants.OS.LINUX:\n            result = _pip_install_torch(\n                python, [\"--index-url\", f\"https://download.pytorch.org/whl/rocm{rocm_version.value}\"]\n            )\n\n        # install torch for NVIDIA\n        if gpu == GPU_OPTION.NVIDIA:\n            if cuda_tag is None:\n                cuda_tag = f\"cu{cuda_version.value.replace('.', '')}\" if cuda_version else DEFAULT_CUDA_TAG\n            result = _pip_install_torch(python, [\"--index-url\", f\"https://download.pytorch.org/whl/{cuda_tag}\"])\n\n        # install torch for Intel Arc GPUs (upstream torch xpu)\n        # https://github.com/comfyanonymous/ComfyUI/pull/7767\n        if gpu == GPU_OPTION.INTEL_ARC:\n            result = _pip_install_torch(python, [\"--extra-index-url\", \"https://download.pytorch.org/whl/xpu\"])\n\n        # install torch for CPU\n        if gpu is None:\n            result = _pip_install_torch(python, [\"--extra-index-url\", \"https://download.pytorch.org/whl/cpu\"])\n\n        if result and result.returncode != 0:\n            rprint(\"Failed to install PyTorch dependencies. Please check your environment (`comfy env`) and try again\")\n            sys.exit(1)\n\n        # install directml for AMD windows\n        if gpu == GPU_OPTION.AMD and plat == constants.OS.WINDOWS:\n            subprocess.run([python, \"-m\", \"pip\", \"install\", \"torch-directml\"], check=True)\n\n        # install torch for Mac M Series\n        if gpu == GPU_OPTION.MAC_M_SERIES:\n            subprocess.run(\n                [\n                    python,\n                    \"-m\",\n                    \"pip\",\n                    \"install\",\n                    \"--pre\",\n                    \"torch\",\n                    \"torchvision\",\n                    \"torchaudio\",\n                    \"--extra-index-url\",\n                    \"https://download.pytorch.org/whl/nightly/cpu\",\n                ],\n                check=True,\n            )\n\n    # install requirements.txt\n    if skip_requirement:\n        return\n    result = subprocess.run([python, \"-m\", \"pip\", \"install\", \"-r\", \"requirements.txt\"], check=False)\n    if result.returncode != 0:\n        rprint(\"Failed to install ComfyUI dependencies. Please check your environment (`comfy env`) and try again.\")\n        sys.exit(1)\n\n\ndef pip_install_manager(repo_dir, python=sys.executable):\n    \"\"\"Install ComfyUI-Manager via manager_requirements.txt.\"\"\"\n    from comfy_cli.command.custom_nodes.cm_cli_util import find_cm_cli\n\n    manager_req_path = os.path.join(repo_dir, constants.MANAGER_REQUIREMENTS_FILE)\n    if not os.path.exists(manager_req_path):\n        rprint(\n            f\"[bold yellow]Warning: {constants.MANAGER_REQUIREMENTS_FILE} not found. \"\n            \"Skipping manager installation (older ComfyUI version?).[/bold yellow]\"\n        )\n        return False\n    result = subprocess.run(\n        [python, \"-m\", \"pip\", \"install\", \"-r\", constants.MANAGER_REQUIREMENTS_FILE],\n        cwd=repo_dir,\n        check=False,\n        capture_output=True,\n        text=True,\n    )\n    if result.returncode != 0:\n        rprint(\"[bold red]Failed to install ComfyUI-Manager.[/bold red]\")\n        if result.stderr:\n            rprint(f\"[dim]{result.stderr.strip()}[/dim]\")\n        return False\n\n    # Clear cache so find_cm_cli() picks up the newly installed module\n    find_cm_cli.cache_clear()\n    return True\n\n\ndef execute(\n    url: str,\n    comfy_path: str,\n    restore: bool,\n    skip_manager: bool,\n    version: str,\n    commit: str | None = None,\n    gpu: constants.GPU_OPTION = None,\n    cuda_version: constants.CUDAVersion | None = None,\n    cuda_tag: str | None = None,\n    rocm_version: constants.ROCmVersion = constants.ROCmVersion.v6_3,\n    plat: constants.OS = None,\n    skip_torch_or_directml: bool = False,\n    skip_requirement: bool = False,\n    fast_deps: bool = False,\n    pr: str | None = None,\n    *args,\n    **kwargs,\n):\n    # Install ComfyUI from a given PR reference.\n    if pr:\n        url = handle_pr_checkout(pr, comfy_path)\n        version = \"nightly\"\n\n    \"\"\"\n    Install ComfyUI from a given URL.\n    \"\"\"\n    if not workspace_manager.skip_prompting:\n        res = ui.prompt_confirm_action(f\"Install from {url} to {comfy_path}?\", True)\n\n        if not res:\n            rprint(\"Aborting...\")\n            raise typer.Exit(code=1)\n\n    rprint(f\"Installing from repository [bold yellow]'{url}'[/bold yellow] to '{comfy_path}'\")\n\n    repo_dir = comfy_path\n    parent_path = os.path.abspath(os.path.join(repo_dir, \"..\"))\n\n    if not os.path.exists(parent_path):\n        os.makedirs(parent_path, exist_ok=True)\n\n    if not os.path.exists(repo_dir):\n        clone_comfyui(url=url, repo_dir=repo_dir)\n\n    if version != \"nightly\":\n        try:\n            checkout_stable_comfyui(version=version, repo_dir=repo_dir, url=url)\n        except GitHubRateLimitError as e:\n            rprint(f\"[bold red]Error checking out ComfyUI version: {e}[/bold red]\")\n            sys.exit(1)\n\n    elif not check_comfy_repo(repo_dir)[0]:\n        # Get actual remote URL for better error message\n        try:\n            repo = git.Repo(repo_dir)\n            remote_urls = [r.url for r in repo.remotes]\n            rprint(\n                f\"[bold red]'{repo_dir}' exists but its remote URL is not a recognized ComfyUI repository.[/bold red]\"\n            )\n            if remote_urls:\n                rprint(f\"[yellow]Found remotes: {', '.join(remote_urls)}[/yellow]\")\n            rprint(\"[yellow]Recognized sources: Comfy-Org, comfyanonymous, drip-art, ltdrdata[/yellow]\")\n        except git.InvalidGitRepositoryError:\n            rprint(f\"[bold red]'{repo_dir}' exists but is not a valid git repository.[/bold red]\")\n        except Exception:\n            rprint(\n                f\"[bold red]'{repo_dir}' already exists. But it is an invalid ComfyUI repository. Remove it and retry.[/bold red]\"\n            )\n        sys.exit(-1)\n\n    # checkout specified commit\n    if commit is not None:\n        os.chdir(repo_dir)\n        subprocess.run([\"git\", \"checkout\", commit], check=True)\n\n    python = ensure_workspace_python(repo_dir)\n    rprint(f\"Using Python: [bold]{python}[/bold]\")\n\n    if not fast_deps:\n        pip_install_comfyui_dependencies(\n            repo_dir,\n            gpu,\n            plat,\n            cuda_version,\n            skip_torch_or_directml,\n            skip_requirement,\n            python=python,\n            rocm_version=rocm_version,\n            cuda_tag=cuda_tag,\n        )\n\n    WorkspaceManager().set_recent_workspace(repo_dir)\n    workspace_manager.setup_workspace_manager(specified_workspace=repo_dir)\n\n    rprint(\"\")\n\n    # install ComfyUI-Manager\n    if skip_manager:\n        rprint(\"Skipping installation of ComfyUI-Manager. (by --skip-manager)\")\n        # Save to config so launch doesn't inject --enable-manager\n        from comfy_cli.config_manager import ConfigManager\n\n        ConfigManager().set(constants.CONFIG_KEY_MANAGER_GUI_MODE, \"disable\")\n    else:\n        rprint(\"\\nInstalling ComfyUI-Manager..\")\n        if not fast_deps:\n            if not pip_install_manager(repo_dir, python=python):\n                # Manager installation failed - disable to prevent launch issues\n                from comfy_cli.config_manager import ConfigManager\n\n                ConfigManager().set(constants.CONFIG_KEY_MANAGER_GUI_MODE, \"disable\")\n                rprint(\"[yellow]Manager not installed. Launch will run without manager flags.[/yellow]\")\n\n    if fast_deps:\n        if python != sys.executable:\n            # Workspace venv needs uv bootstrapped; for the global Python\n            # uv is already available as a comfy-cli dependency.\n            DependencyCompiler.Install_Build_Deps(executable=python)\n        if cuda_tag:\n            # DependencyCompiler expects a dotted version like \"13.0\", not a tag like \"cu130\"\n            digits = cuda_tag[2:]\n            resolved_cuda = f\"{digits[:2]}.{digits[2:]}\"\n        elif cuda_version:\n            resolved_cuda = cuda_version.value\n        else:\n            resolved_cuda = None\n        depComp = DependencyCompiler(\n            cwd=repo_dir,\n            executable=python,\n            gpu=gpu,\n            cuda_version=resolved_cuda,\n            rocm_version=rocm_version.value,\n            skip_torch=skip_torch_or_directml,\n        )\n        depComp.compile_deps()\n        depComp.install_deps()\n        # Install manager separately (not included in DependencyCompiler)\n        if not skip_manager:\n            if not pip_install_manager(repo_dir, python=python):\n                from comfy_cli.config_manager import ConfigManager\n\n                ConfigManager().set(constants.CONFIG_KEY_MANAGER_GUI_MODE, \"disable\")\n                rprint(\"[yellow]Manager not installed. Launch will run without manager flags.[/yellow]\")\n\n    if not skip_manager:\n        try:\n            update_node_id_cache()\n        except (FileNotFoundError, subprocess.CalledProcessError) as e:\n            rprint(f\"Failed to update node id cache: {e}\")\n\n    os.chdir(repo_dir)\n\n    rprint(\"\")\n\n\ndef handle_pr_checkout(pr_ref: str, comfy_path: str) -> str:\n    try:\n        repo_owner, repo_name, pr_number = parse_pr_reference(pr_ref)\n    except ValueError as e:\n        rprint(f\"[bold red]Error parsing PR reference: {e}[/bold red]\")\n        raise typer.Exit(code=1)\n\n    try:\n        if pr_number:\n            pr_info = fetch_pr_info(repo_owner, repo_name, pr_number)\n        else:\n            username, branch = pr_ref.split(\":\", 1)\n            pr_info = find_pr_by_branch(\"comfyanonymous\", \"ComfyUI\", username, branch)\n\n        if not pr_info:\n            rprint(f\"[bold red]PR not found: {pr_ref}[/bold red]\")\n            raise typer.Exit(code=1)\n\n    except Exception as e:\n        rprint(f\"[bold red]Error fetching PR information: {e}[/bold red]\")\n        raise typer.Exit(code=1)\n\n    console.print(\n        Panel(\n            f\"[bold]PR #{pr_info.number}[/bold]: {pr_info.title}\\n\"\n            f\"[yellow]Author[/yellow]: {pr_info.user}\\n\"\n            f\"[yellow]Branch[/yellow]: {pr_info.head_branch}\\n\"\n            f\"[yellow]Source[/yellow]: {pr_info.head_repo_url}\\n\"\n            f\"[yellow]Mergeable[/yellow]: {'✓' if pr_info.mergeable else '✗'}\",\n            title=\"[bold blue]Pull Request Information[/bold blue]\",\n            border_style=\"blue\",\n        )\n    )\n\n    if not workspace_manager.skip_prompting:\n        if not ui.prompt_confirm_action(f\"Install ComfyUI from PR #{pr_info.number}?\", True):\n            rprint(\"Aborting...\")\n            raise typer.Exit(code=1)\n\n    parent_path = os.path.abspath(os.path.join(comfy_path, \"..\"))\n\n    if not os.path.exists(parent_path):\n        os.makedirs(parent_path, exist_ok=True)\n\n    if not os.path.exists(comfy_path):\n        rprint(f\"Cloning base repository to {comfy_path}...\")\n        clone_comfyui(url=pr_info.base_repo_url, repo_dir=comfy_path)\n\n    rprint(f\"Checking out PR #{pr_info.number}: {pr_info.title}\")\n    success = checkout_pr(comfy_path, pr_info)\n    if not success:\n        rprint(\"[bold red]Failed to checkout PR[/bold red]\")\n        raise typer.Exit(code=1)\n\n    rprint(f\"[bold green]✓ Successfully checked out PR #{pr_info.number}[/bold green]\")\n    rprint(f\"[bold yellow]Note:[/bold yellow] You are now on branch pr-{pr_info.number}\")\n\n    return pr_info.base_repo_url\n\n\ndef validate_version(version: str) -> str | None:\n    \"\"\"\n    Validates the version string as 'latest', 'nightly', or a semantically version number.\n\n    Args:\n    version (str): The version string to validate.\n\n    Returns:\n    Optional[str]: The validated version string, or None if invalid.\n\n    Raises:\n    ValueError: If the version string is invalid.\n    \"\"\"\n    if version.lower() in [\"nightly\", \"latest\"]:\n        return version.lower()\n\n    # Remove 'v' prefix if present\n    if version.startswith(\"v\"):\n        version = version[1:]\n\n    try:\n        semver.VersionInfo.parse(version)\n        return version\n    except ValueError as exc:\n        raise ValueError(\n            f\"Invalid version format: {version}. \"\n            \"Please use 'nightly', 'latest', or a valid semantic version (e.g., '1.2.3').\"\n        ) from exc\n\n\nclass GitHubRateLimitError(Exception):\n    \"\"\"Raised when GitHub API rate limit is exceeded\"\"\"\n\n\ndef handle_github_rate_limit(response):\n    # Check rate limit headers\n    remaining = int(response.headers.get(\"x-ratelimit-remaining\", 0))\n    if remaining == 0:\n        reset_time = int(response.headers.get(\"x-ratelimit-reset\", 0))\n        message = f\"Primary rate limit from Github exceeded! Please retry after: {reset_time}\"\n        raise GitHubRateLimitError(message)\n\n    if \"retry-after\" in response.headers:\n        wait_seconds = int(response.headers[\"retry-after\"])\n        message = f\"Rate limit from Github exceeded! Please wait {wait_seconds} seconds before retrying.\"\n        rprint(f\"[yellow]{message}[/yellow]\")\n        raise GitHubRateLimitError(message)\n\n\nclass GithubRelease(TypedDict):\n    \"\"\"\n    A dictionary representing a GitHub release.\n\n    Fields:\n    - version: The version number of the release. (Removed the v prefix)\n    - tag: The tag name of the release.\n    - download_url: The URL to download the release.\n    \"\"\"\n\n    version: semver.VersionInfo | None\n    tag: str\n    download_url: str\n\n\ndef clone_comfyui(url: str, repo_dir: str):\n    \"\"\"\n    Clone the ComfyUI repository from the specified URL.\n    \"\"\"\n    if \"@\" in url:\n        # clone specific branch\n        url, branch = url.rsplit(\"@\", 1)\n        subprocess.run([\"git\", \"clone\", \"-b\", branch, url, repo_dir], check=True)\n    else:\n        subprocess.run([\"git\", \"clone\", url, repo_dir], check=True)\n\n\ndef _resolve_latest_tag_from_local(repo_dir: str) -> tuple[str | None, bool]:\n    \"\"\"Pick the highest stable semver tag from the local clone.\n\n    Returns ``(tag, fetch_ok)``:\n    - ``tag``: the tag string (e.g. ``\"v0.20.1\"``), or ``None`` when no stable\n      semver tag is available (or the directory isn't a git repo).\n    - ``fetch_ok``: whether ``git fetch --tags`` succeeded. Callers can use this\n      to distinguish \"no new releases\" from \"couldn't reach the remote\", which\n      changes the right messaging when falling back to the API.\n\n    Pre-release tags (e.g. ``v1.2.3-rc1``) are skipped to mirror GitHub's\n    ``releases/latest`` behavior. Note that this picks the highest semver tag,\n    which may differ from the release a maintainer has manually marked as\n    \"Latest\" on GitHub — acceptable trade-off given the unauthenticated API's\n    60 req/hr per-IP cap; users can pin a specific version with ``--version``\n    if needed.\n\n    ``git_checkout_tag`` skips its own ``git fetch --tags`` when the resolved\n    tag is already present locally, so on the happy path we fetch exactly once\n    here. Crucially, that also lets the cached-tag offline path succeed: if\n    fetch above fails (``fetch_ok=False``) but a tag is found from disk,\n    ``git_checkout_tag`` will not retry the unreachable fetch.\n    \"\"\"\n    fetch_ok = False\n    try:\n        completed = subprocess.run(\n            [\"git\", \"-C\", repo_dir, \"fetch\", \"--tags\", \"--quiet\"],\n            capture_output=True,\n            text=True,\n            timeout=30,\n        )\n        fetch_ok = completed.returncode == 0\n    except (subprocess.SubprocessError, FileNotFoundError, OSError):\n        # Tolerate timeout / OS-level failure; fall through with whatever's on disk.\n        pass\n\n    try:\n        result = subprocess.run(\n            [\"git\", \"-C\", repo_dir, \"tag\", \"--list\"],\n            capture_output=True,\n            text=True,\n            check=True,\n            timeout=10,\n        )\n    except (subprocess.SubprocessError, FileNotFoundError, OSError):\n        return None, fetch_ok\n\n    best: tuple[semver.VersionInfo, str] | None = None\n    for line in result.stdout.splitlines():\n        tag = line.strip()\n        if not tag:\n            continue\n        try:\n            parsed = semver.VersionInfo.parse(tag.lstrip(\"v\"))\n        except ValueError:\n            continue\n        if parsed.prerelease:\n            continue\n        if best is None or parsed > best[0]:\n            best = (parsed, tag)\n\n    return (best[1] if best else None), fetch_ok\n\n\n_GITHUB_REPO_RE = re.compile(\n    # `github.com[:/]<owner>/<repo>` with optional `.git` and optional setuptools-style\n    # `@branch` suffix (matching what ``clone_comfyui`` accepts via ``rsplit(\"@\", 1)``).\n    # Branch names may contain slashes (`release/1.0`), so the `@<branch>` group is greedy\n    # to end-of-string. The repo segment forbids `@` and `/` to avoid eating those parts.\n    r\"github\\.com[/:]([^/\\s]+)/([^/@\\s]+?)(?:\\.git)?(?:@.+)?/?$\",\n)\n\n\ndef _parse_github_owner_repo(url: str | None) -> tuple[str, str] | None:\n    \"\"\"Parse a GitHub repo URL into ``(owner, repo)``.\n\n    Handles the URL forms ``clone_comfyui`` accepts:\n    - ``https://github.com/owner/repo``\n    - ``https://github.com/owner/repo.git``\n    - ``https://github.com/owner/repo@branch`` (setuptools-style branch suffix)\n    - ``git@github.com:owner/repo`` (SSH form)\n\n    Returns ``None`` for empty input, local paths, or non-GitHub URLs (GitLab,\n    self-hosted, etc.) — the caller decides what to do with that.\n    \"\"\"\n    if not url:\n        return None\n    match = _GITHUB_REPO_RE.search(url)\n    return (match.group(1), match.group(2)) if match else None\n\n\ndef checkout_stable_comfyui(version: str, repo_dir: str, url: str | None = None):\n    \"\"\"\n    Supports installing stable releases of Comfy (semantic versioning) or the 'latest' version.\n\n    For ``version=\"latest\"`` we resolve the highest stable semver tag from the\n    local clone first to avoid burning the unauthenticated GitHub API budget\n    (60 req/hr per IP). The ``releases/latest`` API is only consulted when local\n    resolution turns up nothing.\n\n    The optional ``url`` is the install URL forwarded from ``execute``; it lets\n    the API fallback query the same repo we cloned from (forks included)\n    instead of always asking upstream. Non-GitHub URLs and missing URLs\n    fall back to ``comfyanonymous/ComfyUI`` so the prior behavior is preserved\n    for users who pass a local path or a non-GitHub remote.\n    \"\"\"\n    rprint(f\"Looking for ComfyUI version '{version}'...\")\n    if version == \"latest\":\n        tag, fetch_ok = _resolve_latest_tag_from_local(repo_dir)\n        if tag is None:\n            if not fetch_ok:\n                rprint(\n                    \"[yellow]Could not refresh tags from the remote (offline or auth failure); \"\n                    \"trying GitHub API as a last resort.[/yellow]\"\n                )\n            else:\n                rprint(\"[yellow]No stable release tags found locally; querying GitHub API.[/yellow]\")\n            owner, repo = _parse_github_owner_repo(url) or (\"comfyanonymous\", \"ComfyUI\")\n            selected_release = get_latest_release(owner, repo)\n            if selected_release is None:\n                rprint(f\"Error: No release found for version '{version}'.\")\n                sys.exit(1)\n            tag = str(selected_release[\"tag\"])\n        elif not fetch_ok:\n            # Tag list comes from a cached state — flag it so the user knows\n            # they may not be on the actual newest release.\n            rprint(\n                f\"[yellow]Warning: could not refresh tags from remote; \"\n                f\"using cached tag {tag}. Re-run with network access to get the newest release.[/yellow]\"\n            )\n    else:\n        # For specific versions, directly construct the tag (add 'v' prefix if needed)\n        tag = f\"v{version}\" if not version.startswith(\"v\") else version\n\n    console.print(\n        Panel(\n            f\"Checking out ComfyUI version: [bold cyan]{tag}[/bold cyan]\",\n            title=\"[yellow]ComfyUI Checkout[/yellow]\",\n            border_style=\"green\",\n            expand=False,\n        )\n    )\n\n    with console.status(\"[bold green]Checking out tag...\", spinner=\"dots\"):\n        success = git_checkout_tag(repo_dir, tag)\n        if not success:\n            console.print(f\"\\n[bold red]Failed to checkout tag '{tag}'![/bold red]\")\n            console.print(\"[yellow]The version may not exist. Please check available versions.[/yellow]\")\n            sys.exit(1)\n\n\ndef get_latest_release(repo_owner: str, repo_name: str) -> GithubRelease | None:\n    \"\"\"\n    Fetch the latest release information from GitHub API.\n\n    :param repo_owner: The owner of the repository\n    :param repo_name: The name of the repository\n    :return: A dictionary containing release information, or None if failed\n    \"\"\"\n    url = f\"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest\"\n\n    headers = {}\n    if github_token := os.getenv(\"GITHUB_TOKEN\"):\n        headers[\"Authorization\"] = f\"Bearer {github_token}\"\n\n    try:\n        response = requests.get(url, headers=headers, timeout=5)\n\n        if response.status_code in (403, 429):\n            handle_github_rate_limit(response)\n\n        response.raise_for_status()\n\n        data = response.json()\n\n        # Forks may use non-semver tags (e.g. \"release-2026-04\"); the caller\n        # only needs the raw tag string for git checkout, so let `version`\n        # fall back to None instead of crashing.\n        tag_name = data[\"tag_name\"]\n        try:\n            parsed_version = semver.VersionInfo.parse(tag_name.lstrip(\"v\"))\n        except ValueError:\n            parsed_version = None\n\n        return GithubRelease(\n            tag=tag_name,\n            version=parsed_version,\n            download_url=data[\"zipball_url\"],\n        )\n\n    except requests.RequestException as e:\n        rprint(f\"Error fetching latest release: {e}\")\n        return None\n\n\ndef _parse_pr_reference(\n    pr_ref: str,\n    default_owner: str,\n    default_repo: str,\n) -> tuple[str, str, int | None]:\n    \"\"\"Parse a GitHub PR reference into (repo_owner, repo_name, pr_number).\n\n    Supported formats:\n    - #123                                          → (default_owner, default_repo, 123)\n    - username:branch-name                          → (username, default_repo, None)\n    - https://github.com/owner/repo/pull/123        → (owner, repo, 123)\n    \"\"\"\n    pr_ref = pr_ref.strip()\n\n    if pr_ref.startswith(\"https://github.com/\"):\n        parsed = urlparse(pr_ref)\n        if \"/pull/\" in parsed.path:\n            path_parts = parsed.path.strip(\"/\").split(\"/\")\n            if len(path_parts) >= 4:\n                repo_owner = path_parts[0]\n                repo_name = path_parts[1]\n                pr_number = int(path_parts[3])\n                return repo_owner, repo_name, pr_number\n\n    elif pr_ref.startswith(\"#\"):\n        pr_number = int(pr_ref[1:])\n        return default_owner, default_repo, pr_number\n\n    elif \":\" in pr_ref:\n        username, branch = pr_ref.split(\":\", 1)\n        return username, default_repo, None\n\n    else:\n        raise ValueError(f\"Invalid PR reference format: {pr_ref}\")\n\n\ndef parse_pr_reference(pr_ref: str) -> tuple[str, str, int | None]:\n    return _parse_pr_reference(pr_ref, \"comfyanonymous\", \"ComfyUI\")\n\n\ndef fetch_pr_info(repo_owner: str, repo_name: str, pr_number: int) -> PRInfo:\n    url = f\"https://api.github.com/repos/{repo_owner}/{repo_name}/pulls/{pr_number}\"\n\n    headers = {}\n    if github_token := os.getenv(\"GITHUB_TOKEN\"):\n        headers[\"Authorization\"] = f\"Bearer {github_token}\"\n\n    try:\n        response = requests.get(url, headers=headers, timeout=10)\n\n        if response is None:\n            raise Exception(f\"Failed to fetch PR #{pr_number}: No response from GitHub API\")\n\n        if response.status_code in (403, 429):\n            handle_github_rate_limit(response)\n\n        response.raise_for_status()\n        data = response.json()\n\n        return PRInfo(\n            number=data[\"number\"],\n            head_repo_url=data[\"head\"][\"repo\"][\"clone_url\"],\n            head_branch=data[\"head\"][\"ref\"],\n            base_repo_url=data[\"base\"][\"repo\"][\"clone_url\"],\n            base_branch=data[\"base\"][\"ref\"],\n            title=data[\"title\"],\n            user=data[\"head\"][\"repo\"][\"owner\"][\"login\"],\n            mergeable=data.get(\"mergeable\", True),\n        )\n\n    except requests.RequestException as e:\n        raise Exception(f\"Failed to fetch PR #{pr_number}: {e}\")\n\n\ndef find_pr_by_branch(repo_owner: str, repo_name: str, username: str, branch: str) -> PRInfo | None:\n    url = f\"https://api.github.com/repos/{repo_owner}/{repo_name}/pulls\"\n    params = {\"head\": f\"{username}:{branch}\", \"state\": \"open\"}\n\n    headers = {}\n    if github_token := os.getenv(\"GITHUB_TOKEN\"):\n        headers[\"Authorization\"] = f\"Bearer {github_token}\"\n\n    try:\n        response = requests.get(url, headers=headers, params=params, timeout=10)\n        response.raise_for_status()\n        data = response.json()\n\n        if data:\n            pr_data = data[0]\n            return PRInfo(\n                number=pr_data[\"number\"],\n                head_repo_url=pr_data[\"head\"][\"repo\"][\"clone_url\"],\n                head_branch=pr_data[\"head\"][\"ref\"],\n                base_repo_url=pr_data[\"base\"][\"repo\"][\"clone_url\"],\n                base_branch=pr_data[\"base\"][\"ref\"],\n                title=pr_data[\"title\"],\n                user=pr_data[\"head\"][\"repo\"][\"owner\"][\"login\"],\n                mergeable=pr_data.get(\"mergeable\", True),\n            )\n\n        return None\n\n    except requests.RequestException:\n        return None\n\n\ndef _print_npm_not_found_help(node_version: str) -> None:\n    \"\"\"Print detailed help when npm is not found, with OS-specific instructions.\"\"\"\n    rprint(\"[bold red]npm is not installed or not found in PATH.[/bold red]\")\n    rprint()\n    rprint(\"[yellow]npm is a package manager that usually comes bundled with Node.js.[/yellow]\")\n    rprint(f\"[yellow]Your system has Node.js ({node_version}) but npm was not found.[/yellow]\")\n    rprint()\n\n    current_os = platform.system()\n\n    if current_os == \"Windows\":\n        rprint(\"[bold cyan]How to fix this on Windows:[/bold cyan]\")\n        rprint()\n        rprint(\"  [bold]Step 1:[/bold] Uninstall your current Node.js installation:\")\n        rprint(\"    • Open the Start menu and search for 'Add or remove programs'\")\n        rprint(\"    • Find 'Node.js' in the list and click 'Uninstall'\")\n        rprint()\n        rprint(\"  [bold]Step 2:[/bold] Download and reinstall Node.js:\")\n        rprint(\"    • Go to: [link=https://nodejs.org/]https://nodejs.org/[/link]\")\n        rprint(\"    • Click the green 'Download Node.js (LTS)' button\")\n        rprint(\"    • Run the downloaded installer\")\n        rprint(\"    • [bold]Important:[/bold] Use all default options - do not uncheck anything\")\n        rprint()\n        rprint(\"  [bold]Step 3:[/bold] Restart your terminal:\")\n        rprint(\"    • Close this Command Prompt or PowerShell window completely\")\n        rprint(\"    • Open a new Command Prompt or PowerShell window\")\n        rprint()\n        rprint(\"  [bold]Step 4:[/bold] Verify the installation worked:\")\n        rprint(\"    • Type: [bold]npm --version[/bold]\")\n        rprint(\"    • You should see a version number (e.g., '10.8.0')\")\n        rprint()\n\n    elif current_os == \"Darwin\":  # macOS\n        rprint(\"[bold cyan]How to fix this on macOS:[/bold cyan]\")\n        rprint()\n        rprint(\"  [bold]Option A - Reinstall Node.js (recommended):[/bold]\")\n        rprint()\n        rprint(\"    [bold]Step 1:[/bold] Download Node.js:\")\n        rprint(\"      • Go to: [link=https://nodejs.org/]https://nodejs.org/[/link]\")\n        rprint(\"      • Click the green 'Download Node.js (LTS)' button\")\n        rprint(\"      • Open the downloaded .pkg file and follow the installer\")\n        rprint()\n        rprint(\"    [bold]Step 2:[/bold] Restart your terminal:\")\n        rprint(\"      • Close this Terminal window completely (Cmd+Q)\")\n        rprint(\"      • Open a new Terminal window\")\n        rprint()\n        rprint(\"  [bold]Option B - If you use Homebrew:[/bold]\")\n        rprint(\"    • Run: [bold]brew install node[/bold]\")\n        rprint(\"    • Then restart your terminal\")\n        rprint()\n        rprint(\"  [bold]Verify the installation:[/bold]\")\n        rprint(\"    • Type: [bold]npm --version[/bold]\")\n        rprint(\"    • You should see a version number (e.g., '10.8.0')\")\n        rprint()\n\n    else:  # Linux\n        rprint(\"[bold cyan]How to fix this on Linux:[/bold cyan]\")\n        rprint()\n        rprint(\"  [bold]Option A - Install npm separately (Ubuntu/Debian):[/bold]\")\n        rprint(\"    • Run: [bold]sudo apt update && sudo apt install npm[/bold]\")\n        rprint(\"    • Enter your password when prompted\")\n        rprint()\n        rprint(\"  [bold]Option B - Reinstall Node.js with npm:[/bold]\")\n        rprint()\n        rprint(\"    [bold]Step 1:[/bold] Remove current Node.js:\")\n        rprint(\"      • Ubuntu/Debian: [bold]sudo apt remove nodejs[/bold]\")\n        rprint(\"      • Fedora: [bold]sudo dnf remove nodejs[/bold]\")\n        rprint()\n        rprint(\"    [bold]Step 2:[/bold] Install Node.js (includes npm):\")\n        rprint(\"      • Go to: [link=https://nodejs.org/]https://nodejs.org/[/link]\")\n        rprint(\"      • Or use NodeSource repository for latest version:\")\n        rprint(\"        [bold]curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -[/bold]\")\n        rprint(\"        [bold]sudo apt install -y nodejs[/bold]\")\n        rprint()\n        rprint(\"    [bold]Step 3:[/bold] Restart your terminal:\")\n        rprint(\"      • Close this terminal window and open a new one\")\n        rprint()\n        rprint(\"  [bold]Verify the installation:[/bold]\")\n        rprint(\"    • Type: [bold]npm --version[/bold]\")\n        rprint(\"    • You should see a version number (e.g., '10.8.0')\")\n        rprint()\n\n    rprint(\"[dim]After fixing npm, run your comfy command again.[/dim]\")\n    rprint()\n\n\ndef verify_node_tools() -> bool:\n    \"\"\"Verify that Node.js, npm, and pnpm are available for frontend building\"\"\"\n    try:\n        node_result = subprocess.run([\"node\", \"--version\"], capture_output=True, text=True, check=False)\n    except FileNotFoundError:\n        rprint(\"[bold red]Node.js is not installed or not found in PATH.[/bold red]\")\n        rprint(\"[yellow]To use --frontend-pr, please install Node.js first:[/yellow]\")\n        rprint(\"  • Download from: https://nodejs.org/\")\n        rprint(\"  • Or use a package manager:\")\n        rprint(\"    - macOS: brew install node\")\n        rprint(\"    - Ubuntu/Debian: sudo apt install nodejs npm\")\n        rprint(\"    - Windows: winget install OpenJS.NodeJS\")\n        return False\n\n    if node_result.returncode != 0:\n        rprint(\"[bold red]Node.js is not installed or not working correctly.[/bold red]\")\n        rprint(\"[yellow]To use --frontend-pr, please install Node.js first:[/yellow]\")\n        rprint(\"  • Download from: https://nodejs.org/\")\n        rprint(\"  • Or use a package manager:\")\n        rprint(\"    - macOS: brew install node\")\n        rprint(\"    - Ubuntu/Debian: sudo apt install nodejs npm\")\n        rprint(\"    - Windows: winget install OpenJS.NodeJS\")\n        return False\n\n    node_version = (node_result.stdout or node_result.stderr or \"\").strip()\n    if node_version:\n        rprint(f\"[green]Found Node.js {node_version}[/green]\")\n    else:\n        rprint(\"[green]Found Node.js[/green]\")\n\n    try:\n        npm_result = subprocess.run([\"npm\", \"--version\"], capture_output=True, text=True, check=False)\n    except FileNotFoundError:\n        _print_npm_not_found_help(node_version)\n        return False\n\n    if npm_result.returncode != 0:\n        _print_npm_not_found_help(node_version)\n        return False\n\n    npm_version = npm_result.stdout.strip()\n    if npm_version:\n        rprint(f\"[green]Found npm {npm_version}[/green]\")\n    else:\n        rprint(\"[green]Found npm[/green]\")\n\n    try:\n        pnpm_result = subprocess.run([\"pnpm\", \"--version\"], capture_output=True, text=True, check=False)\n        if pnpm_result.returncode == 0:\n            pnpm_version = pnpm_result.stdout.strip()\n            if pnpm_version:\n                rprint(f\"[green]Found pnpm {pnpm_version}[/green]\")\n            else:\n                rprint(\"[green]Found pnpm[/green]\")\n            return True\n    except FileNotFoundError:\n        pass\n\n    rprint(\"[yellow]pnpm is not installed but is required for the modern frontend.[/yellow]\")\n\n    install_pnpm = Confirm.ask(\n        \"[bold yellow]Install pnpm automatically using npm?[/bold yellow] (This will run: npm install -g pnpm)\"\n    )\n    if not install_pnpm:\n        rprint(\"[bold red]Cannot build frontend without pnpm.[/bold red]\")\n        rprint(\"[yellow]To install manually:[/yellow]\")\n        rprint(\"  npm install -g pnpm\")\n        return False\n\n    rprint(\"[yellow]Installing pnpm...[/yellow]\")\n    install_result = subprocess.run([\"npm\", \"install\", \"-g\", \"pnpm\"], capture_output=True, text=True, check=False)\n\n    if install_result.returncode != 0:\n        rprint(\"[bold red]Failed to install pnpm automatically.[/bold red]\")\n        rprint(f\"[red]Error: {install_result.stderr}[/red]\")\n        rprint(\"[yellow]Please install manually: npm install -g pnpm[/yellow]\")\n        return False\n\n    try:\n        verify_result = subprocess.run([\"pnpm\", \"--version\"], capture_output=True, text=True, check=False)\n    except FileNotFoundError:\n        rprint(\"[bold red]pnpm installation succeeded but pnpm was not found on PATH.[/bold red]\")\n        rprint(\n            \"[yellow]Try restarting your shell or add npm global bin to PATH, then verify with: pnpm --version[/yellow]\"\n        )\n        return False\n\n    if verify_result.returncode != 0:\n        rprint(\"[bold red]pnpm installation failed to verify.[/bold red]\")\n        if verify_result.stderr:\n            rprint(f\"[red]{verify_result.stderr.strip()}[/red]\")\n        return False\n\n    pnpm_version = verify_result.stdout.strip()\n    rprint(f\"[green]Successfully installed pnpm {pnpm_version}[/green]\")\n    return True\n\n\ndef handle_temporary_frontend_pr(frontend_pr: str) -> str | None:\n    \"\"\"Handle temporary frontend PR for launch - returns path to built frontend\"\"\"\n    from comfy_cli.pr_cache import PRCache\n\n    rprint(\"\\n[bold blue]Preparing frontend PR for launch...[/bold blue]\")\n\n    # Verify Node.js tools first\n    if not verify_node_tools():\n        rprint(\"[bold red]Cannot build frontend without Node.js and npm[/bold red]\")\n        return None\n\n    # Parse frontend PR reference\n    try:\n        repo_owner, repo_name, pr_number = parse_frontend_pr_reference(frontend_pr)\n    except ValueError as e:\n        rprint(f\"[bold red]Error parsing frontend PR reference: {e}[/bold red]\")\n        return None\n\n    # Fetch PR info\n    try:\n        if pr_number:\n            pr_info = fetch_pr_info(repo_owner, repo_name, pr_number)\n        else:\n            username, branch = frontend_pr.split(\":\", 1)\n            pr_info = find_pr_by_branch(\"Comfy-Org\", \"ComfyUI_frontend\", username, branch)\n\n        if not pr_info:\n            rprint(f\"[bold red]Frontend PR not found: {frontend_pr}[/bold red]\")\n            return None\n    except Exception as e:\n        rprint(f\"[bold red]Error fetching frontend PR information: {e}[/bold red]\")\n        return None\n\n    # Check cache first\n    cache = PRCache()\n    cached_path = cache.get_cached_frontend_path(pr_info)\n    if cached_path:\n        rprint(f\"[bold green]Using cached frontend build for PR #{pr_info.number}[/bold green]\")\n        rprint(f\"[bold green]PR #{pr_info.number}: {pr_info.title} by {pr_info.user}[/bold green]\")\n        return str(cached_path)\n\n    # Need to build - show PR info\n    console.print(\n        Panel(\n            f\"[bold]Frontend PR #{pr_info.number}[/bold]: {pr_info.title}\\n\"\n            f\"[yellow]Author[/yellow]: {pr_info.user}\\n\"\n            f\"[yellow]Branch[/yellow]: {pr_info.head_branch}\\n\"\n            f\"[yellow]Source[/yellow]: {pr_info.head_repo_url}\",\n            title=\"[bold blue]Building Frontend PR[/bold blue]\",\n            border_style=\"blue\",\n        )\n    )\n\n    # Build in cache directory\n    cache_path = cache.get_frontend_cache_path(pr_info)\n    cache_path.mkdir(parents=True, exist_ok=True)\n\n    # Clone or update repository\n    repo_path = cache_path / \"repo\"\n    if not (repo_path / \".git\").exists():\n        rprint(\"Cloning frontend repository...\")\n        clone_comfyui(url=pr_info.base_repo_url, repo_dir=str(repo_path))\n\n    # Checkout PR\n    rprint(f\"Checking out PR #{pr_info.number}...\")\n    success = checkout_pr(str(repo_path), pr_info)\n    if not success:\n        rprint(\"[bold red]Failed to checkout frontend PR[/bold red]\")\n        return None\n\n    # Build frontend\n    rprint(\"\\n[bold yellow]Building frontend (this may take a moment)...[/bold yellow]\")\n    original_dir = os.getcwd()\n    try:\n        os.chdir(repo_path)\n\n        # Run pnpm install\n        rprint(\"Running pnpm install...\")\n        pnpm_install = subprocess.run([\"pnpm\", \"install\"], capture_output=True, text=True, check=False)\n        if pnpm_install.returncode != 0:\n            rprint(f\"[bold red]pnpm install failed:[/bold red]\\n{pnpm_install.stderr}\")\n            return None\n\n        # Build with vite\n        rprint(\"Building with vite...\")\n        vite_build = subprocess.run([\"npx\", \"vite\", \"build\"], capture_output=True, text=True, check=False)\n        if vite_build.returncode != 0:\n            rprint(f\"[bold red]vite build failed:[/bold red]\\n{vite_build.stderr}\")\n            return None\n\n        # Check if dist exists\n        dist_path = repo_path / \"dist\"\n        if dist_path.exists():\n            # Save cache info\n            cache.save_cache_info(pr_info, cache_path)\n            rprint(\"[bold green]✓ Frontend built and cached successfully[/bold green]\")\n            rprint(f\"[bold green]Using frontend from PR #{pr_info.number}: {pr_info.title}[/bold green]\")\n            rprint(f\"[dim]Cache will expire in {cache.DEFAULT_MAX_CACHE_AGE_DAYS} days[/dim]\")\n            return str(dist_path)\n        else:\n            rprint(\"[bold red]Frontend build completed but dist folder not found[/bold red]\")\n            return None\n\n    finally:\n        os.chdir(original_dir)\n\n\ndef parse_frontend_pr_reference(pr_ref: str) -> tuple[str, str, int | None]:\n    return _parse_pr_reference(pr_ref, \"Comfy-Org\", \"ComfyUI_frontend\")\n"
  },
  {
    "path": "comfy_cli/command/launch.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport os\nimport subprocess\nimport sys\nimport threading\nimport uuid\n\nimport typer\nfrom rich import print\nfrom rich.console import Console\nfrom rich.panel import Panel\n\nfrom comfy_cli import constants, utils\nfrom comfy_cli.command.custom_nodes.cm_cli_util import find_cm_cli, resolve_manager_gui_mode\nfrom comfy_cli.config_manager import ConfigManager\nfrom comfy_cli.env_checker import check_comfy_server_running\nfrom comfy_cli.resolve_python import resolve_workspace_python\nfrom comfy_cli.update import check_for_updates\nfrom comfy_cli.workspace_manager import WorkspaceManager, WorkspaceType\n\nworkspace_manager = WorkspaceManager()\nconsole = Console()\n\n\ndef _get_manager_flags() -> list[str]:\n    \"\"\"Get manager flags based on config mode.\"\"\"\n    mode = resolve_manager_gui_mode(not_installed_value=None)\n\n    if mode is None or mode == \"disable\":\n        return []\n\n    # For enable-* modes, verify cm-cli is available\n    if not find_cm_cli():\n        print(\n            \"[bold yellow]Warning: ComfyUI-Manager (cm-cli) not found. \"\n            \"Manager flags will not be injected.[/bold yellow]\"\n        )\n        return []\n\n    if mode == \"enable-gui\":\n        return [\"--enable-manager\"]\n    elif mode == \"disable-gui\":\n        return [\"--enable-manager\", \"--disable-manager-ui\"]\n    elif mode == \"enable-legacy-gui\":\n        return [\"--enable-manager\", \"--enable-manager-legacy-ui\"]\n    else:\n        print(f\"[bold yellow]Warning: Unknown manager mode '{mode}'. Falling back to --enable-manager.[/bold yellow]\")\n        return [\"--enable-manager\"]  # fallback to default\n\n\ndef launch_comfyui(extra, frontend_pr=None, python=sys.executable):\n    reboot_path = None\n\n    new_env = os.environ.copy()\n\n    session_path = os.path.join(ConfigManager().get_config_path(), \"tmp\", str(uuid.uuid4()))\n    new_env[\"__COMFY_CLI_SESSION__\"] = session_path\n    new_env[\"PYTHONENCODING\"] = \"utf-8\"\n\n    # To minimize the possibility of leaving residue in the tmp directory, use files instead of directories.\n    reboot_path = os.path.join(session_path + \".reboot\")\n\n    extra = extra if extra is not None else []\n\n    # Handle temporary frontend PR\n    if frontend_pr:\n        from comfy_cli.command.install import handle_temporary_frontend_pr\n\n        try:\n            frontend_path = handle_temporary_frontend_pr(frontend_pr)\n            if frontend_path:\n                # Check if --front-end-root is not already specified\n                if not any(arg.startswith(\"--front-end-root\") for arg in extra):\n                    extra = [\"--front-end-root\", frontend_path] + extra\n        except Exception as e:\n            print(f\"[bold red]Failed to prepare frontend PR: {e}[/bold red]\")\n            # Continue with default frontend\n\n    process = None\n\n    if \"COMFY_CLI_BACKGROUND\" not in os.environ:\n        # If not running in background mode, there's no need to use popen. This can prevent the issue of linefeeds occurring with tqdm.\n        while True:\n            res = subprocess.run([python, \"main.py\"] + extra, env=new_env, check=False)\n\n            if reboot_path is None:\n                print(\"[bold red]ComfyUI is not installed.[/bold red]\\n\")\n                exit(res.returncode)\n\n            if not os.path.exists(reboot_path):\n                exit(res.returncode)\n\n            os.remove(reboot_path)\n    else:\n        # If running in background mode without using a popen, broken pipe errors may occur when flushing stdout/stderr.\n        def redirector_stderr():\n            while True:\n                if process is not None:\n                    print(process.stderr.readline(), end=\"\")\n\n        def redirector_stdout():\n            while True:\n                if process is not None:\n                    print(process.stdout.readline(), end=\"\")\n\n        threading.Thread(target=redirector_stderr).start()\n        threading.Thread(target=redirector_stdout).start()\n\n        try:\n            while True:\n                if sys.platform == \"win32\":\n                    process = subprocess.Popen(\n                        [python, \"main.py\"] + extra,\n                        stdout=subprocess.PIPE,\n                        stderr=subprocess.PIPE,\n                        text=True,\n                        env=new_env,\n                        encoding=\"utf-8\",\n                        shell=True,  # win32 only\n                        creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,  # win32 only\n                    )\n                else:\n                    process = subprocess.Popen(\n                        [python, \"main.py\"] + extra,\n                        text=True,\n                        env=new_env,\n                        encoding=\"utf-8\",\n                        stdout=subprocess.PIPE,\n                        stderr=subprocess.PIPE,\n                    )\n\n                process.wait()\n\n                if reboot_path is None:\n                    print(\"[bold red]ComfyUI is not installed.[/bold red]\\n\")\n                    os._exit(1)\n\n                if not os.path.exists(reboot_path):\n                    os._exit(process.returncode)\n\n                os.remove(reboot_path)\n        except KeyboardInterrupt:\n            if process is not None:\n                os._exit(1)\n\n\ndef launch(\n    background: bool = False,\n    extra: list[str] | None = None,\n    frontend_pr: str | None = None,\n):\n    check_for_updates()\n    resolved_workspace = workspace_manager.workspace_path\n\n    if not resolved_workspace:\n        print(\n            \"\\nComfyUI is not available.\\nTo install ComfyUI, you can run:\\n\\n\\tcomfy install\\n\\n\",\n            file=sys.stderr,\n        )\n        raise typer.Exit(code=1)\n\n    if (extra is None or len(extra) == 0) and workspace_manager.workspace_type == WorkspaceType.DEFAULT:\n        launch_extras = workspace_manager.config_manager.config[\"DEFAULT\"].get(\n            constants.CONFIG_KEY_DEFAULT_LAUNCH_EXTRAS, \"\"\n        )\n\n        if launch_extras != \"\":\n            extra = launch_extras.split(\" \")\n\n    print(f\"\\nLaunching ComfyUI from: {resolved_workspace}\\n\")\n\n    # Update the recent workspace\n    workspace_manager.set_recent_workspace(resolved_workspace)\n\n    os.chdir(resolved_workspace)\n    python = resolve_workspace_python(resolved_workspace)\n\n    # Inject manager flags based on config mode\n    manager_flags = _get_manager_flags()\n    if manager_flags:\n        extra = (extra or []) + manager_flags\n\n    if background:\n        background_launch(extra, frontend_pr)\n    else:\n        launch_comfyui(extra, frontend_pr, python=python)\n\n\ndef background_launch(extra, frontend_pr=None):\n    config_background = ConfigManager().background\n    if config_background is not None and utils.is_running(config_background[2]):\n        console.print(\n            \"[bold red]ComfyUI is already running in background.\\nYou cannot start more than one background service.[/bold red]\\n\"\n        )\n        raise typer.Exit(code=1)\n\n    port = 8188\n    listen = \"127.0.0.1\"\n\n    if extra is not None:\n        for i in range(len(extra) - 1):\n            if extra[i] == \"--port\":\n                port = extra[i + 1]\n            if extra[i] == \"--listen\":\n                listen = extra[i + 1]\n\n        if len(extra) > 0:\n            extra = [\"--\"] + extra\n    else:\n        extra = []\n\n    if check_comfy_server_running(port):\n        console.print(\n            f\"[bold red]The {port} port is already in use. A new ComfyUI server cannot be launched.\\n[bold red]\\n\"\n        )\n        raise typer.Exit(code=1)\n\n    cmd = [\n        \"comfy\",\n        f\"--workspace={os.path.abspath(os.getcwd())}\",\n        \"launch\",\n    ]\n\n    # Add frontend PR option if specified\n    if frontend_pr:\n        cmd.extend([\"--frontend-pr\", frontend_pr])\n\n    cmd.extend(extra)\n\n    loop = asyncio.get_event_loop()\n    log = loop.run_until_complete(launch_and_monitor(cmd, listen, port))\n\n    if log is not None:\n        console.print(\n            Panel(\n                \"\".join(log),\n                title=\"[bold red]Error log during ComfyUI execution[/bold red]\",\n                border_style=\"bright_red\",\n            )\n        )\n\n    console.print(\"\\n[bold red]Execution error: failed to launch ComfyUI[/bold red]\\n\")\n    # NOTE: os.exit(0) doesn't work\n    os._exit(1)\n\n\nasync def launch_and_monitor(cmd, listen, port):\n    \"\"\"\n    Monitor the process during the background launch.\n\n    If a success message is captured, exit;\n    otherwise, return the log in case of failure.\n    \"\"\"\n    logging_flag = False\n    log = []\n    logging_lock = threading.Lock()\n\n    # NOTE: To prevent encoding error on Windows platform\n    env = dict(os.environ, PYTHONIOENCODING=\"utf-8\")\n    env[\"COMFY_CLI_BACKGROUND\"] = \"true\"\n\n    if sys.platform == \"win32\":\n        process = subprocess.Popen(\n            cmd,\n            stdout=subprocess.PIPE,\n            stderr=subprocess.PIPE,\n            text=True,\n            env=env,\n            encoding=\"utf-8\",\n            shell=True,  # win32 only\n            creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,  # win32 only\n        )\n    else:\n        process = subprocess.Popen(\n            cmd,\n            stdout=subprocess.PIPE,\n            stderr=subprocess.PIPE,\n            text=True,\n            env=env,\n            encoding=\"utf-8\",\n        )\n\n    def msg_hook(stream):\n        nonlocal log\n        nonlocal logging_flag\n\n        while True:\n            line = stream.readline()\n            if \"Launching ComfyUI from:\" in line:\n                logging_flag = True\n            elif \"To see the GUI go to:\" in line:\n                print(\n                    f\"[bold yellow]ComfyUI is successfully launched in the background.[/bold yellow]\\nTo see the GUI go to: http://{listen}:{port}\"\n                )\n                ConfigManager().config[\"DEFAULT\"][constants.CONFIG_KEY_BACKGROUND] = f\"{(listen, port, process.pid)}\"\n                ConfigManager().write_config()\n\n                # NOTE: os.exit(0) doesn't work.\n                os._exit(0)\n\n            with logging_lock:\n                if logging_flag:\n                    log.append(line)\n\n    stdout_thread = threading.Thread(target=msg_hook, args=(process.stdout,))\n    stderr_thread = threading.Thread(target=msg_hook, args=(process.stderr,))\n\n    stdout_thread.start()\n    stderr_thread.start()\n\n    process.wait()\n\n    return log\n"
  },
  {
    "path": "comfy_cli/command/models/models.py",
    "content": "import contextlib\nimport os\nimport pathlib\nimport time\nfrom typing import Annotated\nfrom urllib.parse import parse_qs, unquote, urlparse\n\nimport requests\nimport typer\nfrom rich import print\nfrom rich.markup import escape\n\nfrom comfy_cli import constants, tracking, ui\nfrom comfy_cli.config_manager import ConfigManager\nfrom comfy_cli.constants import DEFAULT_COMFY_MODEL_PATH\nfrom comfy_cli.file_utils import DownloadException, check_unauthorized, download_file\nfrom comfy_cli.workspace_manager import WorkspaceManager\n\napp = typer.Typer()\n\nworkspace_manager = WorkspaceManager()\nconfig_manager = ConfigManager()\n\n_CIVITAI_SUBDOMAIN_SUFFIXES = tuple(f\".{h}\" for h in constants.CIVITAI_ALLOWED_HOSTS)\n\n\nmodel_path_map = {\n    \"lora\": \"loras\",\n    \"hypernetwork\": \"hypernetworks\",\n    \"checkpoint\": \"checkpoints\",\n    \"textualinversion\": \"embeddings\",\n    \"controlnet\": \"controlnet\",\n}\n\n\ndef get_workspace() -> pathlib.Path:\n    return pathlib.Path(workspace_manager.workspace_path)\n\n\ndef _format_elapsed(seconds: float) -> str:\n    \"\"\"Format elapsed seconds into a human-readable string.\"\"\"\n    rounded = round(seconds, 1)\n    if rounded < 60:\n        return f\"{rounded:.1f}s\"\n    minutes, secs = divmod(int(rounded), 60)\n    if minutes < 60:\n        return f\"{minutes}m {secs}s\"\n    hours, minutes = divmod(minutes, 60)\n    return f\"{hours}h {minutes}m {secs}s\"\n\n\ndef potentially_strip_param_url(path_name: str) -> str:\n    return path_name.split(\"?\")[0]\n\n\ndef check_huggingface_url(url: str) -> tuple[bool, str | None, str | None, str | None, str | None]:\n    \"\"\"\n    Check if the given URL is a Hugging Face URL and extract relevant information.\n\n    Args:\n        url (str): The URL to check.\n\n    Returns:\n        Tuple[bool, Optional[str], Optional[str], Optional[str], Optional[str]]:\n            - is_huggingface_url (bool): True if it's a Hugging Face URL, False otherwise.\n            - repo_id (Optional[str]): The repository ID if it's a Hugging Face URL, None otherwise.\n            - filename (Optional[str]): The filename if present, None otherwise.\n            - folder_name (Optional[str]): The folder name if present, None otherwise.\n            - branch_name (Optional[str]): The git branch name if present, None otherwise.\n    \"\"\"\n    parsed_url = urlparse(url)\n\n    if parsed_url.netloc != \"huggingface.co\" and parsed_url.netloc != \"huggingface.com\":\n        return False, None, None, None, None\n\n    path_parts = [p for p in parsed_url.path.split(\"/\") if p]\n\n    if len(path_parts) < 5 or (path_parts[2] != \"resolve\" and path_parts[2] != \"blob\"):\n        return False, None, None, None, None\n    repo_id = f\"{path_parts[0]}/{path_parts[1]}\"\n    branch_name = path_parts[3]\n\n    remaining_path = \"/\".join(path_parts[4:])\n    folder_name = os.path.dirname(remaining_path) if \"/\" in remaining_path else None\n    filename = os.path.basename(remaining_path)\n\n    # URL decode the filename\n    filename = unquote(filename)\n\n    return True, repo_id, filename, folder_name, branch_name\n\n\ndef check_civitai_url(url: str) -> tuple[bool, bool, int | None, int | None]:\n    \"\"\"\n    Returns:\n        is_civitai_model_url: True if the url is a civitai *web* model url (e.g. /models/12345)\n        is_civitai_api_url: True if the url is a civitai *api* url useful for resolving downloads\n        model_id: The model id (for /models/*), else None\n        version_id: The version id (for /api/download/models/* or ?modelVersionId=), else None\n    \"\"\"\n    try:\n        parsed = urlparse(url)\n        host = (parsed.hostname or \"\").lower()\n        if host not in constants.CIVITAI_ALLOWED_HOSTS and not host.endswith(_CIVITAI_SUBDOMAIN_SUFFIXES):\n            return False, False, None, None\n        p_parts = [p for p in parsed.path.split(\"/\") if p]\n        query = parse_qs(parsed.query)\n\n        if len(p_parts) >= 4 and p_parts[0] == \"api\":\n            # Case 1: /api/download/models/<version_id>\n            # e.g. https://civitai.com/api/download/models/1617665?type=Model&format=SafeTensor\n            if p_parts[1] == \"download\" and p_parts[2] == \"models\":\n                try:\n                    version_id = int(p_parts[3])\n                    return False, True, None, version_id\n                except ValueError:\n                    return False, True, None, None\n\n            # Case 2: /api/v1/model-versions/<version_id>\n            if p_parts[1] == \"v1\" and p_parts[2] in (\"model-versions\", \"modelVersions\"):\n                try:\n                    version_id = int(p_parts[3])\n                    return False, True, None, version_id\n                except ValueError:\n                    return False, True, None, None\n\n        # Case 3: /models/<model_id>[/*] with optional ?modelVersionId=<id>\n        # e.g. https://civitai.com/models/43331\n        #      https://civitai.com/models/43331/majicmix-realistic?modelVersionId=485088\n        if len(p_parts) >= 2 and p_parts[0] == \"models\":\n            try:\n                model_id = int(p_parts[1])\n            except ValueError:\n                return False, False, None, None\n            version_id = None\n            mv = query.get(\"modelVersionId\")\n            if mv and len(mv) > 0:\n                with contextlib.suppress(ValueError):\n                    version_id = int(mv[0])\n            if version_id is None:\n                mv = query.get(\"version\")\n                if mv and len(mv) > 0:\n                    with contextlib.suppress(ValueError):\n                        version_id = int(mv[0])\n            return True, False, model_id, version_id\n\n        return False, False, None, None\n\n    except Exception:\n        print(\"Error parsing CivitAI model URL\")\n        return False, False, None, None\n\n\ndef request_civitai_model_version_api(version_id: int, headers: dict | None = None):\n    # Make a request to the CivitAI API to get the model information\n    response = requests.get(\n        f\"https://civitai.com/api/v1/model-versions/{version_id}\",\n        headers=headers,\n        timeout=10,\n    )\n    response.raise_for_status()  # Raise an error for bad status codes\n\n    model_data = response.json()\n    for file in model_data[\"files\"]:\n        if file.get(\"primary\", False):  # Assuming we want the primary file\n            model_name = file[\"name\"]\n            download_url = file[\"downloadUrl\"]\n            model_type = model_data[\"model\"][\"type\"].lower()\n            basemodel = model_data[\"baseModel\"].replace(\" \", \"\")\n            return model_name, download_url, model_type, basemodel\n\n\ndef request_civitai_model_api(model_id: int, version_id: int = None, headers: dict | None = None):\n    # Make a request to the CivitAI API to get the model information\n    response = requests.get(f\"https://civitai.com/api/v1/models/{model_id}\", headers=headers, timeout=10)\n    response.raise_for_status()  # Raise an error for bad status codes\n\n    model_data = response.json()\n\n    # If version_id is None, use the first version\n    if version_id is None:\n        version_id = model_data[\"modelVersions\"][0][\"id\"]\n\n    # Find the version with the specified version_id\n    for version in model_data[\"modelVersions\"]:\n        if version[\"id\"] == version_id:\n            # Get the model name and download URL from the files array\n            for file in version[\"files\"]:\n                if file.get(\"primary\", False):  # Assuming we want the primary file\n                    model_name = file[\"name\"]\n                    download_url = file[\"downloadUrl\"]\n                    model_type = model_data[\"type\"].lower()\n                    basemodel = version[\"baseModel\"].replace(\" \", \"\")\n                    return model_name, download_url, model_type, basemodel\n\n    # If the specified version_id is not found, raise an error\n    raise ValueError(f\"Version ID {version_id} not found for model ID {model_id}\")\n\n\n@app.command(help=\"Download model file from url\")\n@tracking.track_command(\"model\")\ndef download(\n    _ctx: typer.Context,\n    url: Annotated[\n        str,\n        typer.Option(help=\"The URL from which to download the model.\", show_default=False),\n    ],\n    relative_path: Annotated[\n        str | None,\n        typer.Option(\n            help=\"The relative path from the current workspace to install the model.\",\n            show_default=True,\n        ),\n    ] = None,\n    filename: Annotated[\n        str | None,\n        typer.Option(\n            help=\"The filename to save the model.\",\n            show_default=True,\n        ),\n    ] = None,\n    set_civitai_api_token: Annotated[\n        str | None,\n        typer.Option(\n            \"--set-civitai-api-token\",\n            help=\"Set the CivitAI API token to use for model downloading.\",\n            show_default=False,\n        ),\n    ] = None,\n    set_hf_api_token: Annotated[\n        str | None,\n        typer.Option(\n            \"--set-hf-api-token\",\n            help=\"Set the Hugging Face API token to use for model downloading.\",\n            show_default=False,\n        ),\n    ] = None,\n    downloader: Annotated[\n        str | None,\n        typer.Option(\n            \"--downloader\",\n            help=\"Download backend: 'httpx' (default) or 'aria2' (requires aria2 RPC server).\",\n            show_default=False,\n        ),\n    ] = None,\n):\n    if relative_path is not None:\n        relative_path = os.path.expanduser(relative_path)\n\n    local_filename = None\n    headers = None\n\n    civitai_api_token = config_manager.get_or_override(\n        constants.CIVITAI_API_TOKEN_ENV_KEY, constants.CIVITAI_API_TOKEN_KEY, set_civitai_api_token\n    )\n    hf_api_token = config_manager.get_or_override(\n        constants.HF_API_TOKEN_ENV_KEY, constants.HF_API_TOKEN_KEY, set_hf_api_token\n    )\n\n    resolved_downloader = downloader or config_manager.get(constants.CONFIG_KEY_DEFAULT_DOWNLOADER) or \"httpx\"\n\n    is_civitai_model_url, is_civitai_api_url, model_id, version_id = check_civitai_url(url)\n    is_huggingface_url, repo_id, hf_filename, hf_folder_name, hf_branch_name = check_huggingface_url(url)\n\n    if is_civitai_model_url or is_civitai_api_url:\n        headers = {\n            \"Content-Type\": \"application/json\",\n        }\n        if civitai_api_token is not None:\n            headers[\"Authorization\"] = f\"Bearer {civitai_api_token}\"\n\n    if is_civitai_model_url:\n        local_filename, url, model_type, basemodel = request_civitai_model_api(model_id, version_id, headers)\n\n        model_path = model_path_map.get(model_type)\n\n        if relative_path is None:\n            if model_path is None:\n                model_path = ui.prompt_input(\"Enter model type path (e.g. loras, checkpoints, ...)\", default=\"\")\n\n            relative_path = os.path.join(DEFAULT_COMFY_MODEL_PATH, model_path, basemodel)\n    elif is_civitai_api_url:\n        local_filename, url, model_type, basemodel = request_civitai_model_version_api(version_id, headers)\n\n        model_path = model_path_map.get(model_type)\n\n        if relative_path is None:\n            if model_path is None:\n                model_path = ui.prompt_input(\"Enter model type path (e.g. loras, checkpoints, ...)\", default=\"\")\n\n            relative_path = os.path.join(DEFAULT_COMFY_MODEL_PATH, model_path, basemodel)\n    elif is_huggingface_url:\n        model_id = \"/\".join(url.split(\"/\")[-2:])\n\n        local_filename = potentially_strip_param_url(url.split(\"/\")[-1])\n\n        if relative_path is None:\n            model_path = ui.prompt_input(\"Enter model type path (e.g. loras, checkpoints, ...)\", default=\"\")\n            basemodel = ui.prompt_input(\"Enter base model (e.g. SD1.5, SDXL, ...)\", default=\"\")\n            relative_path = os.path.join(DEFAULT_COMFY_MODEL_PATH, model_path, basemodel)\n    else:\n        print(\"Model source is unknown\")\n\n    if filename is None:\n        if local_filename is None:\n            local_filename = ui.prompt_input(\"Enter filename to save model as\")\n        else:\n            local_filename = ui.prompt_input(\"Enter filename to save model as\", default=local_filename)\n    else:\n        local_filename = filename\n\n    if relative_path is None:\n        relative_path = DEFAULT_COMFY_MODEL_PATH\n\n    if local_filename is None:\n        raise typer.Exit(code=1)\n    if local_filename == \"\":\n        raise DownloadException(\"Filename cannot be empty\")\n\n    local_filepath = get_workspace() / relative_path / local_filename\n\n    if local_filepath.exists():\n        print(f\"[bold red]File already exists: {local_filepath}[/bold red]\")\n        return\n\n    start_time = time.monotonic()\n\n    if is_huggingface_url and check_unauthorized(url, headers):\n        if hf_api_token is None:\n            print(\n                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\"\n            )\n            return\n        else:\n            try:\n                import huggingface_hub\n            except ImportError:\n                print(\"huggingface_hub not found. Installing...\")\n                import subprocess\n\n                from comfy_cli.resolve_python import resolve_workspace_python\n\n                python = resolve_workspace_python(str(get_workspace()))\n                subprocess.check_call([python, \"-m\", \"pip\", \"install\", \"huggingface_hub\"])\n                import huggingface_hub\n\n            print(f\"Downloading model {model_id} from Hugging Face...\")\n            output_path = huggingface_hub.hf_hub_download(\n                repo_id=repo_id,\n                filename=hf_filename,\n                subfolder=hf_folder_name,\n                revision=hf_branch_name,\n                token=hf_api_token,\n                local_dir=get_workspace() / relative_path,\n                cache_dir=get_workspace() / relative_path,\n            )\n            print(f\"Model downloaded successfully to: {output_path}\")\n    else:\n        print(f\"Start downloading URL: {url} into {local_filepath}\")\n        try:\n            download_file(url, local_filepath, headers, downloader=resolved_downloader)\n        except DownloadException as e:\n            # escape() so a dynamic error message containing \"[/]\" or similar\n            # rich-markup syntax doesn't trigger MarkupError or get mis-rendered.\n            print(f\"[bold red]{escape(str(e))}[/bold red]\")\n            raise typer.Exit(code=1) from None\n\n    elapsed = time.monotonic() - start_time\n    print(f\"Done in {_format_elapsed(elapsed)}\")\n\n\n@app.command()\n@tracking.track_command(\"model\")\ndef remove(\n    ctx: typer.Context,\n    relative_path: str = typer.Option(\n        DEFAULT_COMFY_MODEL_PATH,\n        help=\"The relative path from the current workspace where the models are stored.\",\n        show_default=True,\n    ),\n    model_names: list[str] | None = typer.Option(\n        None,\n        help=\"List of model filenames to delete, separated by spaces\",\n        show_default=False,\n    ),\n    confirm: bool = typer.Option(\n        False,\n        help=\"Confirm for deletion and skip the prompt\",\n        show_default=False,\n    ),\n):\n    \"\"\"Remove one or more downloaded models, either by specifying them directly or through an interactive selection.\"\"\"\n    model_dir = get_workspace() / relative_path\n    available_models = list_models(model_dir)\n\n    if not available_models:\n        typer.echo(\"No models found to remove.\")\n        return\n\n    model_dir_resolved = model_dir.resolve()\n\n    to_delete = []\n    # Scenario #1: User provided model names to delete\n    if model_names:\n        # Validate and filter models to delete based on provided names\n        missing_models = []\n        for name in model_names:\n            model_path = (model_dir / name).resolve()\n            if not model_path.is_relative_to(model_dir_resolved):\n                typer.echo(f\"Invalid model path: {name}\")\n                continue\n            if model_path.is_file():\n                to_delete.append(model_path)\n            else:\n                missing_models.append(name)\n\n        if missing_models:\n            typer.echo(\"The following models were not found and cannot be removed: \" + \", \".join(missing_models))\n            if not to_delete:\n                return  # Exit if no valid models were found\n\n    # Scenario #2: User did not provide model names, prompt for selection\n    else:\n        rel_names = [str(model.relative_to(model_dir)) for model in available_models]\n        selections = ui.prompt_multi_select(\"Select models to delete:\", rel_names)\n        if not selections:\n            typer.echo(\"No models selected for deletion.\")\n            return\n        to_delete = [model_dir / selection for selection in selections]\n\n    # Confirm deletion\n    if to_delete and (\n        confirm or ui.prompt_confirm_action(\"Are you sure you want to delete the selected files?\", False)\n    ):\n        for model_path in to_delete:\n            model_path.unlink()\n            typer.echo(f\"Deleted: {model_path}\")\n    else:\n        typer.echo(\"Deletion canceled.\")\n\n\ndef list_models(path: pathlib.Path) -> list[pathlib.Path]:\n    \"\"\"List all model files recursively in the specified directory.\"\"\"\n    if not path.is_dir():\n        return []\n    return sorted(f for f in path.rglob(\"*\") if f.is_file())\n\n\n@app.command(\"list\")\n@tracking.track_command(\"model\")\ndef list_command(\n    ctx: typer.Context,\n    relative_path: str = typer.Option(\n        DEFAULT_COMFY_MODEL_PATH,\n        help=\"The relative path from the current workspace where the models are stored.\",\n        show_default=True,\n    ),\n):\n    \"\"\"Display a list of all models currently downloaded in a table format.\"\"\"\n    model_dir = get_workspace() / relative_path\n    models = list_models(model_dir)\n\n    if not models:\n        typer.echo(\"No models found.\")\n        return\n\n    # Prepare data for table display\n    data = []\n    for model in models:\n        rel = model.relative_to(model_dir)\n        model_type = str(rel.parent) if len(rel.parts) > 1 else \"\"\n        data.append((model.name, model_type, f\"{model.stat().st_size // 1024} KB\"))\n    column_names = [\"Model Name\", \"Type\", \"Size\"]\n    ui.display_table(data, column_names)\n"
  },
  {
    "path": "comfy_cli/command/pr_command.py",
    "content": "\"\"\"PR cache management commands.\n\nThis module provides CLI commands for managing the PR cache, including:\n- Listing cached PR builds\n- Cleaning specific or all cached builds\n- Displaying cache information in a user-friendly format\n\"\"\"\n\nimport typer\nfrom rich import print as rprint\nfrom rich.console import Console\nfrom rich.table import Table\n\nfrom comfy_cli import tracking\nfrom comfy_cli.pr_cache import PRCache\n\napp = typer.Typer(help=\"Manage PR cache\")\nconsole = Console()\n\n\n@app.command(\"list\", help=\"List cached PR builds\")\n@tracking.track_command()\ndef list_cached() -> None:\n    \"\"\"List all cached PR builds.\"\"\"\n    cache = PRCache()\n    cached_frontends = cache.list_cached_frontends()\n\n    if not cached_frontends:\n        rprint(\"[yellow]No cached PR builds found[/yellow]\")\n        return\n\n    table = Table(title=\"Cached Frontend PR Builds\")\n    table.add_column(\"PR #\", style=\"cyan\")\n    table.add_column(\"Title\", style=\"white\")\n    table.add_column(\"Author\", style=\"green\")\n    table.add_column(\"Age\", style=\"yellow\")\n    table.add_column(\"Size (MB)\", style=\"magenta\")\n\n    for info in cached_frontends:\n        age = cache.get_cache_age(info.get(\"cached_at\", \"\"))\n        table.add_row(\n            str(info.get(\"pr_number\", \"?\")),\n            info.get(\"pr_title\", \"Unknown\")[:50],  # Truncate long titles\n            info.get(\"user\", \"Unknown\"),\n            age,\n            f\"{info.get('size_mb', 0):.1f}\",\n        )\n\n    console.print(table)\n\n    # Show cache settings\n    rprint(\n        f\"\\n[dim]Cache settings: Max age: {cache.DEFAULT_MAX_CACHE_AGE_DAYS} days, \"\n        f\"Max items: {cache.DEFAULT_MAX_CACHE_ITEMS}[/dim]\"\n    )\n\n\n@app.command(\"clean\", help=\"Clean PR cache\")\n@tracking.track_command()\ndef clean_cache(\n    pr_number: int = typer.Argument(None, help=\"Specific PR number to clean (omit to clean all)\"),\n    yes: bool = typer.Option(False, \"--yes\", \"-y\", help=\"Skip confirmation\"),\n) -> None:\n    \"\"\"Clean cached PR builds.\"\"\"\n    cache = PRCache()\n\n    if pr_number:\n        if not yes:\n            confirm = typer.confirm(f\"Remove cache for PR #{pr_number}?\")\n            if not confirm:\n                rprint(\"[yellow]Cancelled[/yellow]\")\n                return\n        cache.clean_frontend_cache(pr_number)\n        rprint(f\"[green]✓ Cleaned cache for PR #{pr_number}[/green]\")\n    else:\n        if not yes:\n            cached = cache.list_cached_frontends()\n            if cached:\n                rprint(f\"[yellow]This will remove {len(cached)} cached PR build(s)[/yellow]\")\n                confirm = typer.confirm(\"Remove all cached PR builds?\")\n                if not confirm:\n                    rprint(\"[yellow]Cancelled[/yellow]\")\n                    return\n        cache.clean_frontend_cache()\n        rprint(\"[green]✓ Cleaned all PR cache[/green]\")\n"
  },
  {
    "path": "comfy_cli/command/run.py",
    "content": "import json\nimport os\nimport sys\nimport time\nimport urllib.error\nimport urllib.parse\nimport uuid\nfrom datetime import timedelta\nfrom urllib import request\n\nimport typer\nfrom rich import print as pprint\nfrom rich.progress import BarColumn, Column, Progress, Table, TimeElapsedColumn\nfrom websocket import WebSocket, WebSocketException, WebSocketTimeoutException\n\nfrom comfy_cli.env_checker import check_comfy_server_running\nfrom comfy_cli.workflow_to_api import WorkflowConversionError, convert_ui_to_api\nfrom comfy_cli.workspace_manager import WorkspaceManager\n\nworkspace_manager = WorkspaceManager()\n\n\ndef is_ui_workflow(workflow) -> bool:\n    return (\n        isinstance(workflow, dict)\n        and isinstance(workflow.get(\"nodes\"), list)\n        and isinstance(workflow.get(\"links\"), list)\n    )\n\n\ndef _validate_api_workflow(workflow):\n    \"\"\"Return the workflow dict if it has the shape of API format, else None.\"\"\"\n    if not isinstance(workflow, dict) or not workflow:\n        return None\n    node = workflow[next(iter(workflow))]\n    if not isinstance(node, dict) or \"class_type\" not in node:\n        return None\n    return workflow\n\n\ndef fetch_object_info(host: str, port: int, timeout: int) -> dict:\n    \"\"\"GET ``/object_info`` from the running ComfyUI server.\n\n    The response describes every loaded node class's input schema and is what\n    the converter uses to map widget values to input names, fill defaults, etc.\n    \"\"\"\n    url = f\"http://{host}:{port}/object_info\"\n    try:\n        with request.urlopen(url, timeout=timeout) as resp:\n            body = resp.read()\n    except urllib.error.HTTPError as e:\n        body = e.read().decode(\"utf-8\", errors=\"replace\").strip()\n        pprint(f\"[bold red]Failed to fetch /object_info (HTTP {e.code}): {body[:500]}[/bold red]\")\n        raise typer.Exit(code=1) from e\n    except urllib.error.URLError as e:\n        pprint(f\"[bold red]Failed to fetch /object_info: {e.reason}[/bold red]\")\n        raise typer.Exit(code=1) from e\n    except TimeoutError as e:\n        pprint(f\"[bold red]Failed to fetch /object_info: timed out after {timeout}s[/bold red]\")\n        raise typer.Exit(code=1) from e\n    try:\n        return json.loads(body)\n    except json.JSONDecodeError as e:\n        pprint(\"[bold red]Failed to fetch /object_info: server returned invalid JSON[/bold red]\")\n        raise typer.Exit(code=1) from e\n\n\ndef execute(\n    workflow: str,\n    host,\n    port,\n    wait=True,\n    verbose=False,\n    local_paths=False,\n    timeout=30,\n    api_key: str | None = None,\n):\n    workflow_name = os.path.abspath(os.path.expanduser(workflow))\n    if not os.path.isfile(workflow):\n        pprint(\n            f\"[bold red]Specified workflow file not found: {workflow}[/bold red]\",\n            file=sys.stderr,\n        )\n        raise typer.Exit(code=1)\n\n    if not check_comfy_server_running(port, host):\n        pprint(f\"[bold red]ComfyUI not running on specified address ({host}:{port})[/bold red]\")\n        raise typer.Exit(code=1)\n\n    try:\n        with open(workflow_name, encoding=\"utf-8\") as f:\n            raw_workflow = json.load(f)\n    except OSError as e:\n        pprint(f\"[bold red]Unable to read workflow file: {e}[/bold red]\")\n        raise typer.Exit(code=1) from e\n    except json.JSONDecodeError as e:\n        pprint(f\"[bold red]Specified workflow file is not valid JSON: {e}[/bold red]\")\n        raise typer.Exit(code=1) from e\n\n    if is_ui_workflow(raw_workflow):\n        pprint(\"[yellow]Detected UI-format workflow, converting to API format...[/yellow]\")\n        object_info = fetch_object_info(host, port, timeout)\n        try:\n            workflow = convert_ui_to_api(raw_workflow, object_info)\n        except WorkflowConversionError as e:\n            pprint(f\"[bold red]Workflow conversion failed: {e}[/bold red]\")\n            raise typer.Exit(code=1) from e\n        except Exception as e:\n            # The converter is experimental; an unexpected crash here is a bug\n            # in our code, not user error. Show a clean message and a pointer.\n            pprint(\n                f\"[bold red]Workflow conversion crashed unexpectedly: {type(e).__name__}: {e}[/bold red]\\n\"\n                \"[yellow]The UI-to-API converter is experimental. Please report this at[/yellow]\\n\"\n                \"[yellow]  https://github.com/Comfy-Org/comfy-cli/issues[/yellow]\\n\"\n                \"[yellow]and attach the workflow file if possible.[/yellow]\"\n            )\n            if verbose:\n                import traceback\n\n                traceback.print_exc()\n            raise typer.Exit(code=1) from e\n        if not workflow:\n            pprint(\"[bold red]Workflow conversion produced no executable nodes[/bold red]\")\n            raise typer.Exit(code=1)\n    else:\n        workflow = _validate_api_workflow(raw_workflow)\n        if not workflow:\n            pprint(\"[bold red]Specified workflow does not appear to be an API workflow json file[/bold red]\")\n            raise typer.Exit(code=1)\n\n    progress = None\n    start = time.time()\n    if wait:\n        pprint(f\"Executing workflow: {workflow_name}\")\n        progress = ExecutionProgress()\n        progress.start()\n    else:\n        print(f\"Queuing workflow: {workflow_name}\")\n\n    execution = WorkflowExecution(workflow, host, port, verbose, progress, local_paths, timeout, api_key=api_key)\n\n    try:\n        if wait:\n            execution.connect()\n        execution.queue()\n        if wait:\n            execution.watch_execution()\n            end = time.time()\n            progress.stop()\n            progress = None\n\n            if len(execution.outputs) > 0:\n                pprint(\"[bold green]\\nOutputs:[/bold green]\")\n\n                for f in execution.outputs:\n                    pprint(f)\n\n            elapsed = timedelta(seconds=end - start)\n            pprint(f\"[bold green]\\nWorkflow execution completed ({elapsed})[/bold green]\")\n        else:\n            pprint(\"[bold green]Workflow queued[/bold green]\")\n    except WebSocketTimeoutException:\n        pprint(\n            f\"[bold red]Error: WebSocket timed out after {timeout}s waiting for server response.[/bold red]\\n\"\n            \"[yellow]For long-running workflows, increase the timeout: comfy run --workflow <file> --timeout 300[/yellow]\"\n        )\n        raise typer.Exit(code=1)\n    except (WebSocketException, ConnectionError, OSError) as e:\n        pprint(f\"[bold red]Error: Lost connection to ComfyUI server: {e}[/bold red]\")\n        raise typer.Exit(code=1)\n    finally:\n        if progress:\n            progress.stop()\n\n\nclass ExecutionProgress(Progress):\n    def get_renderables(self):\n        table_columns = (\n            (Column(no_wrap=True) if isinstance(_column, str) else _column.get_table_column().copy())\n            for _column in self.columns\n        )\n\n        for task in self.tasks:\n            percent = \"[progress.percentage]{task.percentage:>3.0f}%\".format(task=task)  # noqa\n            if task.fields.get(\"progress_type\") == \"overall\":\n                overall_table = Table.grid(*table_columns, padding=(0, 1), expand=self.expand)\n                overall_table.add_row(BarColumn().render(task), percent, TimeElapsedColumn().render(task))\n                yield overall_table\n            else:\n                yield self.make_tasks_table([task])\n\n\nclass WorkflowExecution:\n    def __init__(self, workflow, host, port, verbose, progress, local_paths, timeout=30, api_key: str | None = None):\n        self.workflow = workflow\n        self.host = host\n        self.port = port\n        self.verbose = verbose\n        self.local_paths = local_paths\n        self.client_id = str(uuid.uuid4())\n        self.outputs = []\n        self.progress = progress\n        self.remaining_nodes = set(self.workflow.keys())\n        self.total_nodes = len(self.remaining_nodes)\n        if progress:\n            self.overall_task = self.progress.add_task(\"\", total=self.total_nodes, progress_type=\"overall\")\n        self.current_node = None\n        self.progress_task = None\n        self.progress_node = None\n        self.prompt_id = None\n        self.ws = None\n        self.timeout = timeout\n        self.api_key = api_key\n\n    def connect(self):\n        self.ws = WebSocket()\n        self.ws.connect(f\"ws://{self.host}:{self.port}/ws?clientId={self.client_id}\")\n\n    def queue(self):\n        data: dict = {\"prompt\": self.workflow, \"client_id\": self.client_id}\n        if self.api_key:\n            data[\"extra_data\"] = {\"api_key_comfy_org\": self.api_key}\n        req = request.Request(\n            f\"http://{self.host}:{self.port}/prompt\",\n            json.dumps(data).encode(\"utf-8\"),\n        )\n        try:\n            resp = request.urlopen(req)\n            body = json.loads(resp.read())\n\n            self.prompt_id = body[\"prompt_id\"]\n        except urllib.error.HTTPError as e:\n            message = \"An unknown error occurred\"\n            if e.status == 500:\n                # This is normally just the generic internal server error\n                message = e.read().decode()\n            elif e.status == 400:\n                # Bad Request - workflow failed validation on the server\n                body = json.loads(e.read())\n                if body[\"node_errors\"].keys():\n                    message = json.dumps(body[\"node_errors\"], indent=2)\n\n            self.progress.stop()\n\n            pprint(f\"[bold red]Error running workflow\\n{message}[/bold red]\")\n            raise typer.Exit(code=1)\n\n    def watch_execution(self):\n        self.ws.settimeout(self.timeout)\n        while True:\n            message = self.ws.recv()\n            if isinstance(message, str):\n                message = json.loads(message)\n                if not self.on_message(message):\n                    break\n\n    def update_overall_progress(self):\n        self.progress.update(self.overall_task, completed=self.total_nodes - len(self.remaining_nodes))\n\n    def get_node_title(self, node_id):\n        node = self.workflow.get(node_id)\n        if node is None:\n            return str(node_id)\n        if \"_meta\" in node and \"title\" in node[\"_meta\"]:\n            return node[\"_meta\"][\"title\"]\n        return node[\"class_type\"]\n\n    def log_node(self, type, node_id):\n        if not self.verbose:\n            return\n\n        node = self.workflow.get(node_id)\n        if node is None:\n            pprint(f\"{type} : [bright_black]({node_id})[/]\")\n            return\n        class_type = node[\"class_type\"]\n        title = self.get_node_title(node_id)\n\n        if title != class_type:\n            title += f\"[bright_black] - {class_type}[/]\"\n        title += f\"[bright_black] ({node_id})[/]\"\n\n        pprint(f\"{type} : {title}\")\n\n    def format_image_path(self, img):\n        filename = img[\"filename\"]\n        subfolder = img[\"subfolder\"] if \"subfolder\" in img else None\n        output_type = img[\"type\"] or \"output\"\n\n        if self.local_paths:\n            if subfolder:\n                filename = os.path.join(subfolder, filename)\n\n            return os.path.join(workspace_manager.get_workspace_path()[0], output_type, filename)\n\n        query = urllib.parse.urlencode(img)\n        return f\"http://{self.host}:{self.port}/view?{query}\"\n\n    def on_message(self, message):\n        data = message[\"data\"] if \"data\" in message else {}\n        # Skip any messages that aren't about our prompt\n        if \"prompt_id\" not in data or data[\"prompt_id\"] != self.prompt_id:\n            return True\n\n        if message[\"type\"] == \"executing\":\n            return self.on_executing(data)\n        elif message[\"type\"] == \"execution_cached\":\n            self.on_cached(data)\n        elif message[\"type\"] == \"progress\":\n            self.on_progress(data)\n        elif message[\"type\"] == \"executed\":\n            self.on_executed(data)\n        elif message[\"type\"] == \"execution_error\":\n            self.on_error(data)\n\n        return True\n\n    def on_executing(self, data):\n        if self.progress_task:\n            self.progress.remove_task(self.progress_task)\n            self.progress_task = None\n\n        if data[\"node\"] is None:\n            return False\n        else:\n            if self.current_node:\n                self.remaining_nodes.discard(self.current_node)\n                self.update_overall_progress()\n            self.current_node = data[\"node\"]\n            self.log_node(\"Executing\", data[\"node\"])\n        return True\n\n    def on_cached(self, data):\n        nodes = data[\"nodes\"]\n        for n in nodes:\n            self.remaining_nodes.discard(n)\n            self.log_node(\"Cached\", n)\n        self.update_overall_progress()\n\n    def on_progress(self, data):\n        node = data[\"node\"]\n        if self.progress_node != node:\n            self.progress_node = node\n            if self.progress_task:\n                self.progress.remove_task(self.progress_task)\n\n            self.progress_task = self.progress.add_task(\n                self.get_node_title(node), total=data[\"max\"], progress_type=\"node\"\n            )\n        self.progress.update(self.progress_task, completed=data[\"value\"])\n\n    def on_executed(self, data):\n        self.remaining_nodes.discard(data[\"node\"])\n        self.update_overall_progress()\n\n        if \"output\" not in data:\n            return\n\n        output = data[\"output\"]\n\n        if output is None or \"images\" not in output:\n            return\n\n        for img in output[\"images\"]:\n            self.outputs.append(self.format_image_path(img))\n\n    def on_error(self, data):\n        pprint(f\"[bold red]Error running workflow\\n{json.dumps(data, indent=2)}[/bold red]\")\n        raise typer.Exit(code=1)\n"
  },
  {
    "path": "comfy_cli/config_manager.py",
    "content": "import configparser\nimport os\nfrom importlib.metadata import version\n\nfrom comfy_cli import constants, logging\nfrom comfy_cli.utils import get_os, is_running, singleton\n\n\n@singleton\nclass ConfigManager:\n    def __init__(self):\n        self.config = configparser.ConfigParser()\n        self.background: tuple[str, int, int] | None = None\n        self.load()\n\n    @staticmethod\n    def get_config_path():\n        return constants.DEFAULT_CONFIG[get_os()]\n\n    def get_config_file_path(self):\n        return os.path.join(self.get_config_path(), \"config.ini\")\n\n    def write_config(self):\n        config_file_path = os.path.join(self.get_config_path(), \"config.ini\")\n        dir_path = os.path.dirname(config_file_path)\n        if not os.path.exists(dir_path):\n            os.mkdir(dir_path)\n\n        with open(config_file_path, \"w\") as configfile:\n            self.config.write(configfile)\n\n    def set(self, key, value):\n        \"\"\"\n        Set a key-value pair in the config file.\n        \"\"\"\n        self.config[\"DEFAULT\"][key] = value\n        self.write_config()  # Write changes to file immediately\n\n    def get(self, key):\n        \"\"\"\n        Get a value from the config file. Returns None if the key does not exist.\n        \"\"\"\n        return self.config[\"DEFAULT\"].get(key, None)  # Returns None if the key does not exist\n\n    def get_bool(self, key) -> bool | None:\n        \"\"\"\n        Get a boolean value from the config file using configparser's built-in\n        getboolean, which accepts: true/false, yes/no, on/off, 1/0 (case-insensitive).\n\n        Returns None if the key does not exist.\n        \"\"\"\n        if not self.config.has_option(\"DEFAULT\", key):\n            return None\n        return self.config.getboolean(\"DEFAULT\", key)\n\n    def get_or_override(self, env_key: str, config_key: str, set_value: str | None = None) -> str | None:\n        \"\"\"\n        Resolves and conditionally stores a config value.\n\n        The selected value and action is determined by the following priority:\n\n        1. Use CLI-provided `--set-*` value (if not None), and save it to config via `set()`.\n        2. Use process environment variable if exists (empty strings are allowed).\n        3. Otherwise, use the current config value via `get()`.\n\n        Returns None if the selected value is an empty string.\n        \"\"\"\n\n        if set_value is not None:\n            self.set(config_key, set_value)\n            return set_value or None\n        elif env_key in os.environ:\n            return os.environ[env_key] or None\n        else:\n            return self.get(config_key) or None\n\n    def load(self):\n        config_file_path = self.get_config_file_path()\n        if os.path.exists(config_file_path):\n            self.config = configparser.ConfigParser()\n            self.config.read(config_file_path)\n\n        # TODO: We need a policy for clearing the tmp directory.\n        tmp_path = os.path.join(self.get_config_path(), \"tmp\")\n        if not os.path.exists(tmp_path):\n            os.makedirs(tmp_path)\n\n        if constants.CONFIG_KEY_BACKGROUND in self.config[\"DEFAULT\"]:\n            bg_info = self.config[\"DEFAULT\"][constants.CONFIG_KEY_BACKGROUND].strip(\"()\").split(\",\")\n            bg_info = [item.strip().strip(\"'\") for item in bg_info]\n            self.background = bg_info[0], int(bg_info[1]), int(bg_info[2])\n\n            if not is_running(self.background[2]):\n                self.remove_background()\n\n    def get_env_data(self):\n        \"\"\"\n        Get environment data as a list of tuples for display.\n\n        Returns:\n            List[Tuple[str, str]]: List of (key, value) tuples for environment data.\n        \"\"\"\n        data = []\n        data.append((\"Config Path\", self.get_config_file_path()))\n\n        launch_extras = \"\"\n        if self.config.has_option(\"DEFAULT\", constants.CONFIG_KEY_DEFAULT_WORKSPACE):\n            data.append(\n                (\n                    \"Default ComfyUI workspace\",\n                    self.config[\"DEFAULT\"][constants.CONFIG_KEY_DEFAULT_WORKSPACE],\n                )\n            )\n            launch_extras = self.config[\"DEFAULT\"].get(constants.CONFIG_KEY_DEFAULT_LAUNCH_EXTRAS, \"\")\n        else:\n            data.append((\"Default ComfyUI workspace\", \"No default ComfyUI workspace\"))\n\n        if launch_extras == \"\":\n            launch_extras = \"[bold red]None[/bold red]\"\n\n        data.append((\"Default ComfyUI launch extra options\", launch_extras))\n\n        if self.config.has_option(\"DEFAULT\", constants.CONFIG_KEY_RECENT_WORKSPACE):\n            data.append(\n                (\n                    \"Recent ComfyUI workspace\",\n                    self.config[\"DEFAULT\"][constants.CONFIG_KEY_RECENT_WORKSPACE],\n                )\n            )\n        else:\n            data.append((\"Recent ComfyUI workspace\", \"No recent run\"))\n\n        tracking = self.get_bool(constants.CONFIG_KEY_ENABLE_TRACKING)\n        if tracking is not None:\n            data.append(\n                (\n                    \"Tracking Analytics\",\n                    \"Enabled\" if tracking else \"Disabled\",\n                )\n            )\n\n        if self.config.has_option(\"DEFAULT\", constants.CONFIG_KEY_BACKGROUND):\n            bg_info = self.background\n            if bg_info:\n                data.append(\n                    (\n                        \"Background ComfyUI\",\n                        f\"http://{bg_info[0]}:{bg_info[1]} (pid={bg_info[2]})\",\n                    )\n                )\n        else:\n            data.append((\"Background ComfyUI\", \"[bold red]No[/bold red]\"))\n\n        return data\n\n    def remove_background(self):\n        del self.config[\"DEFAULT\"][constants.CONFIG_KEY_BACKGROUND]\n        self.write_config()\n        self.background = None\n\n    def get_cli_version(self):\n        # Note: this approach should work for users installing the CLI via\n        # PyPi and Homebrew (e.g., pip install comfy-cli)\n        try:\n            return version(\"comfy-cli\")\n        except Exception as e:\n            logging.debug(f\"Error occurred while retrieving CLI version using importlib.metadata: {e}\")\n\n        return \"0.0.0\"\n"
  },
  {
    "path": "comfy_cli/constants.py",
    "content": "import os\nfrom enum import Enum\n\n\nclass OS(str, Enum):\n    WINDOWS = \"windows\"\n    MACOS = \"macos\"\n    LINUX = \"linux\"\n\n\nclass PROC(str, Enum):\n    X86_64 = \"x86_64\"\n    ARM = \"arm\"\n\n\nCOMFY_GITHUB_URL = \"https://github.com/comfyanonymous/ComfyUI\"\n\nMANAGER_REQUIREMENTS_FILE = \"manager_requirements.txt\"\n\nDEFAULT_COMFY_MODEL_PATH = \"models\"\nDEFAULT_COMFY_WORKSPACE = {\n    OS.WINDOWS: os.path.join(os.path.expanduser(\"~\"), \"Documents\", \"comfy\", \"ComfyUI\"),\n    OS.MACOS: os.path.join(os.path.expanduser(\"~\"), \"Documents\", \"comfy\", \"ComfyUI\"),\n    OS.LINUX: os.path.join(os.path.expanduser(\"~\"), \"comfy\", \"ComfyUI\"),\n}\n\nDEFAULT_CONFIG = {\n    OS.WINDOWS: os.path.join(os.path.expanduser(\"~\"), \"AppData\", \"Local\", \"comfy-cli\"),\n    OS.MACOS: os.path.join(os.path.expanduser(\"~\"), \"Library\", \"Application Support\", \"comfy-cli\"),\n    OS.LINUX: os.path.join(os.path.expanduser(\"~\"), \".config\", \"comfy-cli\"),\n}\n\nCONTEXT_KEY_WORKSPACE = \"workspace\"\nCONTEXT_KEY_RECENT = \"recent\"\nCONTEXT_KEY_HERE = \"here\"\n\nCONFIG_KEY_DEFAULT_WORKSPACE = \"default_workspace\"\nCONFIG_KEY_DEFAULT_LAUNCH_EXTRAS = \"default_launch_extras\"\nCONFIG_KEY_RECENT_WORKSPACE = \"recent_workspace\"\nCONFIG_KEY_ENABLE_TRACKING = \"enable_tracking\"\nCONFIG_KEY_USER_ID = \"user_id\"\nCONFIG_KEY_INSTALL_EVENT_TRIGGERED = \"install_event_triggered\"\nCONFIG_KEY_BACKGROUND = \"background\"\nCONFIG_KEY_MANAGER_GUI_ENABLED = \"manager_gui_enabled\"  # Legacy, kept for backward compatibility\nCONFIG_KEY_MANAGER_GUI_MODE = \"manager_gui_mode\"  # Valid: \"disable\", \"enable-gui\", \"disable-gui\", \"enable-legacy-gui\"\nCONFIG_KEY_UV_COMPILE_DEFAULT = \"uv_compile_default\"\n\nCIVITAI_API_TOKEN_KEY = \"civitai_api_token\"\nCIVITAI_API_TOKEN_ENV_KEY = \"CIVITAI_API_TOKEN\"\nCIVITAI_ALLOWED_HOSTS: tuple[str, ...] = (\"civitai.com\", \"civitai.red\")\nHF_API_TOKEN_KEY = \"hf_api_token\"\nHF_API_TOKEN_ENV_KEY = \"HF_API_TOKEN\"\n\nARIA2_SERVER_ENV_KEY = \"COMFYUI_MANAGER_ARIA2_SERVER\"\nARIA2_SECRET_ENV_KEY = \"COMFYUI_MANAGER_ARIA2_SECRET\"\nCONFIG_KEY_DEFAULT_DOWNLOADER = \"default_downloader\"\n\nDEFAULT_TRACKING_VALUE = True\n\nCOMFY_LOCK_YAML_FILE = \"comfy.lock.yaml\"\n\n# TODO: figure out a better way to check if this is a comfy repo\nCOMFY_ORIGIN_URL_CHOICES = {\n    \"git@github.com:Comfy-Org/ComfyUI.git\",\n    \"git@github.com:comfyanonymous/ComfyUI.git\",\n    \"git@github.com:drip-art/comfy.git\",\n    \"git@github.com:ltdrdata/ComfyUI.git\",\n    \"https://github.com/Comfy-Org/ComfyUI.git\",\n    \"https://github.com/comfyanonymous/ComfyUI.git\",\n    \"https://github.com/drip-art/ComfyUI.git\",\n    \"https://github.com/ltdrdata/ComfyUI.git\",\n    \"https://github.com/Comfy-Org/ComfyUI\",\n    \"https://github.com/comfyanonymous/ComfyUI\",\n    \"https://github.com/drip-art/ComfyUI\",\n    \"https://github.com/ltdrdata/ComfyUI\",\n}\n\n\nclass CUDAVersion(str, Enum):\n    v13_0 = \"13.0\"\n    v12_9 = \"12.9\"\n    v12_8 = \"12.8\"\n    v12_6 = \"12.6\"\n    v12_4 = \"12.4\"\n    v12_1 = \"12.1\"\n    v11_8 = \"11.8\"\n\n\nclass ROCmVersion(str, Enum):\n    v7_1 = \"7.1\"\n    v7_0 = \"7.0\"\n    v6_3 = \"6.3\"\n    v6_2 = \"6.2\"\n    v6_1 = \"6.1\"\n\n\nclass GPU_OPTION(str, Enum):\n    CPU = None\n    NVIDIA = \"nvidia\"\n    AMD = \"amd\"\n    INTEL_ARC = \"intel_arc\"\n    MAC_M_SERIES = \"mac_m_series\"\n    MAC_INTEL = \"mac_intel\"\n\n\n# Referencing supported pt extension from ComfyUI\n# https://github.com/comfyanonymous/ComfyUI/blob/a88b0ebc2d2f933c94e42aa689c42e836eedaf3c/folder_paths.py#L5\nSUPPORTED_PT_EXTENSIONS = (\".ckpt\", \".pt\", \".bin\", \".pth\", \".safetensors\")\n\nNODE_ZIP_FILENAME = \"node.zip\"\n\n# The default minor version series to download from python-build-standalone.\n# The exact patch version is resolved dynamically from the release metadata.\nDEFAULT_STANDALONE_PYTHON_MINOR_VERSION = \"3.12\"\n"
  },
  {
    "path": "comfy_cli/cuda_detect.py",
    "content": "\"\"\"Auto-detect CUDA driver version and resolve the best PyTorch wheel suffix.\"\"\"\n\nfrom __future__ import annotations\n\nimport ctypes\nimport logging\nimport os\nimport platform\nimport re\nimport subprocess\n\nlogger = logging.getLogger(__name__)\n\nPYTORCH_CUDA_WHEELS: list[str] = [\n    \"cu130\",\n    \"cu129\",\n    \"cu128\",\n    \"cu126\",\n    \"cu124\",\n    \"cu121\",\n    \"cu118\",\n]\n\nDEFAULT_CUDA_TAG = \"cu126\"\n\n\ndef _load_libcuda() -> ctypes.CDLL:\n    \"\"\"Load the NVIDIA CUDA driver library.\n\n    Raises OSError when the library cannot be found on any known path.\n    \"\"\"\n    system = platform.system()\n\n    if system == \"Windows\":\n        candidates = [\"nvcuda.dll\"]\n    else:\n        candidates = [\n            \"libcuda.so.1\",\n            \"/usr/lib/wsl/lib/libcuda.so.1\",\n            \"/usr/lib64/nvidia/libcuda.so.1\",\n            \"/usr/lib/x86_64-linux-gnu/libcuda.so.1\",\n        ]\n\n    for path in candidates:\n        try:\n            return ctypes.CDLL(path)\n        except OSError:\n            continue\n\n    raise OSError(\"Could not load CUDA driver library from any known path\")\n\n\ndef _detect_via_ctypes() -> int | None:\n    \"\"\"Return the raw driver version int from cuDriverGetVersion, or None.\"\"\"\n    try:\n        libcuda = _load_libcuda()\n    except OSError:\n        logger.debug(\"Failed to load libcuda\")\n        return None\n\n    try:\n        ret = libcuda.cuInit(0)\n        if ret != 0:\n            logger.debug(\"cuInit returned %d\", ret)\n            return None\n\n        version = ctypes.c_int()\n        ret = libcuda.cuDriverGetVersion(ctypes.byref(version))\n        if ret != 0:\n            logger.debug(\"cuDriverGetVersion returned %d\", ret)\n            return None\n\n        return version.value\n    except Exception:\n        logger.debug(\"ctypes CUDA call failed\", exc_info=True)\n        return None\n\n\ndef _detect_via_nvidia_smi() -> tuple[int, int] | None:\n    \"\"\"Parse CUDA version from nvidia-smi output, or return None.\"\"\"\n    try:\n        output = subprocess.check_output(\n            [\"nvidia-smi\"],\n            text=True,\n            timeout=10,\n            stderr=subprocess.DEVNULL,\n        )\n    except (FileNotFoundError, subprocess.SubprocessError):\n        return None\n\n    match = re.search(r\"CUDA Version:\\s*(\\d+)\\.(\\d+)\", output)\n    if not match:\n        return None\n\n    return int(match.group(1)), int(match.group(2))\n\n\ndef detect_cuda_driver_version() -> tuple[int, int] | None:\n    \"\"\"Detect the CUDA driver version.\n\n    Tries ctypes (cuDriverGetVersion) first, then falls back to nvidia-smi.\n    Returns (major, minor) or None if detection fails entirely.\n    \"\"\"\n    saved = os.environ.get(\"CUDA_VISIBLE_DEVICES\")\n    try:\n        if saved is not None:\n            os.environ.pop(\"CUDA_VISIBLE_DEVICES\", None)\n\n        raw = _detect_via_ctypes()\n        if raw is not None:\n            major = raw // 1000\n            minor = (raw % 1000) // 10\n            return major, minor\n\n        return _detect_via_nvidia_smi()\n    finally:\n        if saved is not None:\n            os.environ[\"CUDA_VISIBLE_DEVICES\"] = saved\n\n\ndef resolve_cuda_wheel(driver_version: tuple[int, int]) -> str | None:\n    \"\"\"Map a driver CUDA version to the best PyTorch wheel suffix.\n\n    Picks the highest wheel tag whose CUDA version <= the driver version.\n    Returns None if the driver is too old for any known wheel.\n    \"\"\"\n    drv_major, drv_minor = driver_version\n\n    for tag in PYTORCH_CUDA_WHEELS:\n        digits = tag[2:]\n        whl_major = int(digits[:2])\n        whl_minor = int(digits[2:])\n\n        if (whl_major, whl_minor) <= (drv_major, drv_minor):\n            return tag\n\n    return None\n"
  },
  {
    "path": "comfy_cli/env_checker.py",
    "content": "\"\"\"\nModule for checking various env and state conditions.\n\"\"\"\n\nimport os\nimport sys\n\nimport requests\nfrom rich.console import Console\n\nfrom comfy_cli.config_manager import ConfigManager\nfrom comfy_cli.utils import singleton\n\nconsole = Console()\n\n\ndef format_python_version(version_info):\n    \"\"\"\n    Formats the Python version string to display the major and minor version numbers.\n\n    If the minor version is greater than 8, the version is displayed in normal text.\n    If the minor version is 8 or less, the version is displayed in bold red text to indicate an older version.\n\n    Args:\n        version_info (sys.version_info): The Python version information\n\n    Returns:\n        str: The formatted Python version string.\n    \"\"\"\n    if version_info.major == 3 and version_info.minor > 8:\n        return f\"{version_info.major}.{version_info.minor}.{version_info.micro}\"\n    return f\"[bold red]{version_info.major}.{version_info.minor}.{version_info.micro}[/bold red]\"\n\n\ndef check_comfy_server_running(port=8188, host=\"localhost\"):\n    \"\"\"\n    Checks if the Comfy server is running by making a GET request to the /history endpoint.\n\n    Returns:\n        bool: True if the Comfy server is running, False otherwise.\n    \"\"\"\n    try:\n        response = requests.get(f\"http://{host}:{port}/history\")\n        return response.status_code == 200\n    except requests.exceptions.RequestException:\n        return False\n\n\n@singleton\nclass EnvChecker:\n    \"\"\"\n    Provides an `EnvChecker` class to check the current environment and print information about it.\n\n    - `virtualenv_path`: The path to the current virtualenv, or \"Not Used\" if not in a virtualenv.\n    - `conda_env`: The name of the current conda environment, or \"Not Used\" if not in a conda environment.\n    - `python_version`: The version information for the current Python installation.\n    - `currently_in_comfy_repo`: A boolean indicating whether the current directory is part of the Comfy repository.\n\n    The `EnvChecker` class is a singleton that checks the current environment\n    and stores information about the Python version, virtualenv path, conda\n    environment, and whether the current directory is part of the Comfy\n    repository.\n\n\n    The `print()` method of the `EnvChecker` class displays the collected\n    environment information in a formatted table.\n    \"\"\"\n\n    def __init__(self):\n        self.virtualenv_path = None\n        self.conda_env = None\n        self.python_version = sys.version_info\n        self.check()\n\n    def is_isolated_env(self):\n        return self.virtualenv_path or self.conda_env\n\n    def get_isolated_env(self):\n        if self.virtualenv_path:\n            return self.virtualenv_path\n\n        if self.conda_env:\n            return self.conda_env\n\n        return None\n\n    def check(self):\n        self.virtualenv_path = os.environ.get(\"VIRTUAL_ENV\") if os.environ.get(\"VIRTUAL_ENV\") else None\n        self.conda_env = os.environ.get(\"CONDA_DEFAULT_ENV\") if os.environ.get(\"CONDA_DEFAULT_ENV\") else None\n\n    def fill_print_table(self):\n        data = []\n        data.append((\"Python Version\", format_python_version(sys.version_info)))\n        data.append((\"Python Executable\", sys.executable))\n        data.append(\n            (\n                \"Virtualenv Path\",\n                self.virtualenv_path if self.virtualenv_path else \"Not Used\",\n            )\n        )\n        data.append((\"Conda Env\", self.conda_env if self.conda_env else \"Not Used\"))\n\n        config_data = ConfigManager().get_env_data()\n        data.extend(config_data)\n\n        if check_comfy_server_running():\n            data.append(\n                (\n                    \"Comfy Server Running\",\n                    \"[bold green]Yes[/bold green]\\nhttp://localhost:8188\",\n                )\n            )\n        else:\n            data.append((\"Comfy Server Running\", \"[bold red]No[/bold red]\"))\n\n        return data\n"
  },
  {
    "path": "comfy_cli/file_utils.py",
    "content": "import json\nimport os\nimport pathlib\nimport subprocess\nimport time\nimport zipfile\nfrom http import HTTPStatus\n\nimport httpx\nimport requests\nfrom pathspec import PathSpec\n\nfrom comfy_cli import constants, ui\n\n\nclass DownloadException(Exception):\n    pass\n\n\ndef guess_status_code_reason(status_code: int, message: str) -> str:\n    if status_code == 401:\n\n        def parse_json(input_data):\n            try:\n                # Check if the input is a byte string\n                if isinstance(input_data, bytes):\n                    # Decode the byte string to a regular string\n                    input_data = input_data.decode(\"utf-8\")\n\n                # Parse the string as JSON\n                return json.loads(input_data)\n\n            except json.JSONDecodeError as e:\n                # Handle JSON decoding error\n                print(f\"JSON decoding error: {e}\")\n\n        msg_json = parse_json(message)\n        if msg_json is not None:\n            if \"message\" in msg_json:\n                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\"\n        return f\"Unauthorized download ({status_code}), you might need to manually log into a browser to download this\"\n    elif status_code == 403:\n        return f\"Forbidden url ({status_code}), you might need to manually log into a browser to download this\"\n    elif status_code == 404:\n        return \"File not found on server (404)\"\n    return f\"Unknown error occurred (status code: {status_code})\"\n\n\ndef check_unauthorized(url: str, headers: dict | None = None) -> bool:\n    \"\"\"\n    Perform a GET request to the given URL and check if the response status code is 401 (Unauthorized).\n\n    Args:\n        url (str): The URL to send the GET request to.\n        headers (Optional[dict]): Optional headers to include in the request.\n\n    Returns:\n        bool: True if the response status code is 401, False otherwise.\n    \"\"\"\n    try:\n        response = requests.get(url, headers=headers, allow_redirects=True, stream=True)\n        return response.status_code == 401\n    except requests.RequestException:\n        # If there's an error making the request, we can't determine if it's unauthorized\n        return False\n\n\ndef _poll_aria2_download(download) -> None:\n    \"\"\"Poll an aria2 download until completion, showing progress.\"\"\"\n    import time\n\n    from rich.progress import (\n        BarColumn,\n        DownloadColumn,\n        Progress,\n        TimeRemainingColumn,\n        TransferSpeedColumn,\n    )\n\n    with Progress(\n        \"[progress.description]{task.description}\",\n        BarColumn(),\n        DownloadColumn(),\n        TransferSpeedColumn(),\n        TimeRemainingColumn(),\n        transient=True,\n    ) as progress:\n        task = progress.add_task(\"Downloading...\", total=None)\n\n        while True:\n            try:\n                download.update()\n            except Exception as e:\n                raise DownloadException(f\"Lost connection to aria2 RPC server: {e}\") from e\n\n            if download.total_length > 0:\n                progress.update(task, total=download.total_length, completed=download.completed_length)\n\n            if download.is_complete:\n                if download.total_length > 0:\n                    progress.update(task, completed=download.total_length)\n                break\n            elif download.has_failed:\n                raise DownloadException(\n                    f\"aria2 download failed: {download.error_message} (code: {download.error_code})\"\n                )\n            elif download.is_removed:\n                raise DownloadException(\"aria2 download was removed before completion\")\n\n            time.sleep(0.5)\n\n\ndef _download_file_aria2(url: str, local_filepath: pathlib.Path, headers: dict | None = None) -> None:\n    \"\"\"Download a file using aria2 RPC.\"\"\"\n    try:\n        import aria2p\n    except ImportError:\n        raise DownloadException(\n            \"aria2p is required for aria2 downloads. Install it with: pip install aria2p\\n\"\n            \"You also need a running aria2c daemon. See: https://aria2.github.io/\"\n        ) from None\n\n    server = os.environ.get(constants.ARIA2_SERVER_ENV_KEY)\n    if not server:\n        raise DownloadException(\n            f\"aria2 downloader selected but {constants.ARIA2_SERVER_ENV_KEY} environment variable is not set.\\n\"\n            f\"Set it to your aria2 RPC server URL, e.g.: export {constants.ARIA2_SERVER_ENV_KEY}=http://localhost:6800\"\n        )\n\n    secret = os.environ.get(constants.ARIA2_SECRET_ENV_KEY, \"\")\n\n    from urllib.parse import urlparse\n\n    if \"://\" not in server:\n        server = f\"http://{server}\"\n    parsed = urlparse(server)\n    if not parsed.hostname:\n        raise DownloadException(f\"Invalid aria2 server URL (cannot parse hostname): {server}\")\n    host = f\"{parsed.scheme}://{parsed.hostname}\"\n    port = parsed.port or 6800\n\n    try:\n        api = aria2p.API(aria2p.Client(host=host, port=port, secret=secret))\n    except Exception as e:\n        raise DownloadException(f\"Failed to connect to aria2 RPC server at {server}: {e}\") from e\n\n    options = {\n        \"dir\": str(local_filepath.parent),\n        \"out\": local_filepath.name,\n    }\n\n    if headers:\n        options[\"header\"] = [f\"{k}: {v}\" for k, v in headers.items()]\n\n    try:\n        download = api.add_uris([url], options=options)\n    except Exception as e:\n        raise DownloadException(f\"Failed to add download to aria2: {e}\") from e\n\n    _poll_aria2_download(download)\n\n    if not local_filepath.exists():\n        raise DownloadException(f\"aria2 download completed but file not found at expected path: {local_filepath}\")\n\n\n_VALID_DOWNLOADERS = {\"httpx\", \"aria2\"}\n\n_DOWNLOAD_MAX_RETRIES = 3\n_DOWNLOAD_RETRY_BACKOFF = 2  # seconds multiplier\n_DOWNLOAD_TIMEOUT = httpx.Timeout(10.0, read=300.0)\n_TRANSIENT_EXCEPTIONS = (\n    httpx.TimeoutException,\n    httpx.NetworkError,\n    httpx.ProtocolError,\n    httpx.ProxyError,\n)\n# HTTP statuses that typically indicate a transient server-side or rate-limit\n# problem worth retrying with backoff. Auth/not-found/redirect statuses stay\n# out of this set so they fail fast.\n_RETRIABLE_STATUSES = frozenset({408, 429, 500, 502, 503, 504})\n\n\nclass _TransientHTTPStatusError(Exception):\n    \"\"\"Retriable HTTP status returned by the server (e.g. 500/503/429).\"\"\"\n\n    def __init__(self, status_code: int, reason: str):\n        self.status_code = status_code\n        self.reason = reason\n        super().__init__(f\"HTTP {status_code}: {reason}\")\n\n\n_RETRIABLE_EXCEPTIONS = _TRANSIENT_EXCEPTIONS + (_TransientHTTPStatusError,)\n\n\ndef _cleanup_partial(filepath: pathlib.Path) -> None:\n    \"\"\"Remove a partially downloaded file if it exists.\"\"\"\n    try:\n        filepath.unlink(missing_ok=True)\n    except OSError:\n        pass\n\n\ndef _friendly_network_error(exc: Exception) -> str:\n    \"\"\"Return a user-friendly description of a network error.\"\"\"\n    if isinstance(exc, _TransientHTTPStatusError):\n        try:\n            phrase = HTTPStatus(exc.status_code).phrase\n            return f\"the server returned HTTP {exc.status_code} {phrase}\"\n        except ValueError:\n            return f\"the server returned HTTP {exc.status_code}\"\n    if isinstance(exc, httpx.InvalidURL):\n        return f\"invalid URL ({exc})\"\n    if isinstance(exc, httpx.ReadTimeout):\n        return \"the server stopped sending data (read timeout)\"\n    if isinstance(exc, httpx.ConnectTimeout):\n        return \"could not connect to the server (connect timeout)\"\n    if isinstance(exc, httpx.TimeoutException):\n        return f\"the operation timed out ({type(exc).__name__})\"\n    if isinstance(exc, httpx.NetworkError):\n        return f\"a network error occurred ({type(exc).__name__}: {exc})\"\n    if isinstance(exc, httpx.ProtocolError):\n        return f\"a protocol error occurred ({type(exc).__name__}: {exc})\"\n    if isinstance(exc, httpx.ProxyError):\n        return f\"a proxy error occurred ({type(exc).__name__}: {exc})\"\n    return str(exc)\n\n\ndef _download_file_httpx(\n    url: str,\n    local_filepath: pathlib.Path,\n    headers: dict | None = None,\n    *,\n    state: dict | None = None,\n) -> None:\n    \"\"\"Download a file using httpx streaming. Raises on HTTP or network errors.\n\n    If ``state`` is provided, ``state[\"file_opened\"]`` is set to True immediately\n    after the output file is opened for writing. Callers use this to distinguish\n    failures raised *before* the destination was touched (HTTP errors, ConnectError,\n    etc.) from failures raised *after* writing started (mid-stream ReadTimeout),\n    so they can avoid deleting an unrelated pre-existing file at the destination.\n    \"\"\"\n    with httpx.stream(\"GET\", url, follow_redirects=True, headers=headers, timeout=_DOWNLOAD_TIMEOUT) as response:\n        if response.status_code != 200:\n            try:\n                error_body = response.read()\n            except _TRANSIENT_EXCEPTIONS:\n                error_body = \"\"\n            status_reason = guess_status_code_reason(response.status_code, error_body)\n            if response.status_code in _RETRIABLE_STATUSES:\n                raise _TransientHTTPStatusError(response.status_code, status_reason)\n            raise DownloadException(f\"Failed to download file.\\n{status_reason}\")\n\n        content_length = response.headers.get(\"Content-Length\")\n        total = int(content_length) if content_length is not None else None\n        if total is not None:\n            description = f\"Downloading {total // 1024 // 1024} MB\"\n        else:\n            description = \"Downloading...\"\n\n        with open(local_filepath, \"wb\") as f:\n            if state is not None:\n                state[\"file_opened\"] = True\n            for data in ui.show_progress(\n                response.iter_bytes(),\n                total,\n                description=description,\n            ):\n                f.write(data)\n\n\ndef download_file(url: str, local_filepath: pathlib.Path, headers: dict | None = None, downloader: str = \"httpx\"):\n    \"\"\"Helper function to download a file.\"\"\"\n    if downloader not in _VALID_DOWNLOADERS:\n        raise DownloadException(\n            f\"Unknown downloader: {downloader!r}. Valid options: {', '.join(sorted(_VALID_DOWNLOADERS))}\"\n        )\n\n    local_filepath.parent.mkdir(parents=True, exist_ok=True)\n\n    if downloader == \"aria2\":\n        return _download_file_aria2(url, local_filepath, headers)\n\n    last_exc: Exception | None = None\n    state: dict = {\"file_opened\": False}\n\n    for attempt in range(_DOWNLOAD_MAX_RETRIES):\n        state[\"file_opened\"] = False\n        try:\n            _download_file_httpx(url, local_filepath, headers, state=state)\n            return\n        except _RETRIABLE_EXCEPTIONS as exc:\n            last_exc = exc\n            # Only clean up if _download_file_httpx actually opened the destination —\n            # otherwise we'd delete an unrelated pre-existing file at the same path.\n            if state[\"file_opened\"]:\n                _cleanup_partial(local_filepath)\n            if attempt < _DOWNLOAD_MAX_RETRIES - 1:\n                wait = _DOWNLOAD_RETRY_BACKOFF * (attempt + 1)\n                print(f\"Download error (attempt {attempt + 1}/{_DOWNLOAD_MAX_RETRIES}): {_friendly_network_error(exc)}\")\n                print(f\"Retrying in {wait}s...\")\n                time.sleep(wait)\n        except (httpx.HTTPError, httpx.InvalidURL) as exc:\n            # Non-retriable httpx errors (e.g. UnsupportedProtocol, TooManyRedirects,\n            # DecodingError, InvalidURL). Fail fast and convert to DownloadException\n            # so callers only need to handle one error type.\n            # InvalidURL inherits directly from Exception (not HTTPError), hence the\n            # explicit inclusion.\n            if state[\"file_opened\"]:\n                _cleanup_partial(local_filepath)\n            raise DownloadException(f\"Download failed: {_friendly_network_error(exc)}\") from exc\n        except KeyboardInterrupt:\n            # Only prompt/cleanup if we actually opened the destination this attempt.\n            # If the interrupt arrived during connection setup, there is no partial\n            # file and the destination may hold an unrelated pre-existing file.\n            if state[\"file_opened\"]:\n                delete_eh = ui.prompt_confirm_action(\"Download interrupted, cleanup files?\", True)\n                if delete_eh:\n                    _cleanup_partial(local_filepath)\n            raise\n\n    raise DownloadException(\n        f\"Download failed after {_DOWNLOAD_MAX_RETRIES} attempts: \"\n        f\"{_friendly_network_error(last_exc)}\\n\"\n        f\"Please try again later.\"\n    ) from last_exc\n\n\ndef _load_comfyignore_spec(ignore_filename: str = \".comfyignore\") -> PathSpec | None:\n    if not os.path.exists(ignore_filename):\n        return None\n    try:\n        with open(ignore_filename, encoding=\"utf-8\") as ignore_file:\n            patterns = [line.strip() for line in ignore_file if line.strip() and not line.lstrip().startswith(\"#\")]\n    except OSError:\n        return None\n\n    if not patterns:\n        return None\n\n    return PathSpec.from_lines(\"gitwildmatch\", patterns)\n\n\ndef list_git_tracked_files(base_path: str | os.PathLike = \".\") -> list[str]:\n    try:\n        result = subprocess.check_output(\n            [\"git\", \"-C\", os.fspath(base_path), \"ls-files\"],\n            text=True,\n        )\n    except (subprocess.SubprocessError, FileNotFoundError):\n        return []\n\n    return [line for line in result.splitlines() if line.strip()]\n\n\ndef _normalize_path(path: str) -> str:\n    rel_path = os.path.relpath(path, start=\".\")\n    if rel_path == \".\":\n        return \"\"\n    return rel_path.replace(\"\\\\\", \"/\")\n\n\ndef _is_force_included(rel_path: str, include_prefixes: list[str]) -> bool:\n    return any(rel_path == prefix or rel_path.startswith(prefix + \"/\") for prefix in include_prefixes if prefix)\n\n\ndef zip_files(zip_filename, includes=None):\n    \"\"\"Zip git-tracked files respecting optional .comfyignore patterns.\"\"\"\n    includes = includes or []\n    include_prefixes: list[str] = [_normalize_path(os.path.normpath(include.lstrip(\"/\"))) for include in includes]\n\n    included_paths: set[str] = set()\n    git_files: list[str] = []\n\n    ignore_spec = _load_comfyignore_spec()\n\n    def should_ignore(rel_path: str) -> bool:\n        if not ignore_spec:\n            return False\n        if _is_force_included(rel_path, include_prefixes):\n            return False\n        return ignore_spec.match_file(rel_path)\n\n    zip_target = os.fspath(zip_filename)\n    zip_abs_path = os.path.abspath(zip_target)\n    zip_basename = os.path.basename(zip_abs_path)\n\n    git_files = list_git_tracked_files(\".\")\n    if not git_files:\n        print(\"Warning: Not in a git repository or git not installed. Zipping all files.\")\n\n    with zipfile.ZipFile(zip_target, \"w\", zipfile.ZIP_DEFLATED) as zipf:\n        if git_files:\n            for file_path in git_files:\n                if file_path == zip_basename:\n                    continue\n\n                rel_path = _normalize_path(file_path)\n                if should_ignore(rel_path):\n                    continue\n\n                actual_path = os.path.normpath(file_path)\n                if os.path.abspath(actual_path) == zip_abs_path:\n                    continue\n                if os.path.exists(actual_path):\n                    arcname = rel_path or os.path.basename(actual_path)\n                    zipf.write(actual_path, arcname)\n                    included_paths.add(rel_path)\n                else:\n                    print(f\"File not found. Not including in zip: {file_path}\")\n        else:\n            for root, dirs, files in os.walk(\".\"):\n                if \".git\" in dirs:\n                    dirs.remove(\".git\")\n                dirs[:] = [d for d in dirs if not should_ignore(_normalize_path(os.path.join(root, d)))]\n                for file in files:\n                    file_path = os.path.join(root, file)\n                    rel_path = _normalize_path(file_path)\n                    if (\n                        os.path.abspath(file_path) == zip_abs_path\n                        or rel_path in included_paths\n                        or should_ignore(rel_path)\n                    ):\n                        continue\n                    arcname = rel_path or file_path\n                    zipf.write(file_path, arcname)\n                    included_paths.add(rel_path)\n\n        for include_dir in includes:\n            include_dir = os.path.normpath(include_dir.lstrip(\"/\"))\n            rel_include = _normalize_path(include_dir)\n\n            if os.path.isfile(include_dir):\n                if not should_ignore(rel_include) and rel_include not in included_paths:\n                    arcname = rel_include or include_dir\n                    zipf.write(include_dir, arcname)\n                    included_paths.add(rel_include)\n                continue\n\n            if not os.path.exists(include_dir):\n                print(f\"Warning: Included directory '{include_dir}' does not exist, creating empty directory\")\n                arcname = rel_include or include_dir\n                if not arcname.endswith(\"/\"):\n                    arcname = arcname + \"/\"\n                zipf.writestr(arcname, \"\")\n                continue\n\n            for root, dirs, files in os.walk(include_dir):\n                dirs[:] = [d for d in dirs if not should_ignore(_normalize_path(os.path.join(root, d)))]\n                for file in files:\n                    file_path = os.path.join(root, file)\n                    rel_path = _normalize_path(file_path)\n                    if (\n                        os.path.abspath(file_path) == zip_abs_path\n                        or rel_path in included_paths\n                        or should_ignore(rel_path)\n                    ):\n                        continue\n                    arcname = rel_path or file_path\n                    zipf.write(file_path, arcname)\n                    included_paths.add(rel_path)\n\n\ndef upload_file_to_signed_url(signed_url: str, file_path: str):\n    with open(file_path, \"rb\") as f:\n        headers = {\"Content-Type\": \"application/zip\"}\n        response = requests.put(signed_url, data=f, headers=headers)\n\n        if response.status_code == 200:\n            print(\"Upload successful.\")\n        else:\n            raise Exception(f\"Upload failed with status code: {response.status_code}. Error: {response.text}\")\n\n\ndef extract_package_as_zip(file_path: pathlib.Path, extract_path: pathlib.Path):\n    try:\n        with zipfile.ZipFile(file_path, \"r\") as zip_ref:\n            zip_ref.extractall(extract_path)\n        print(f\"Extracted zip file to {extract_path}\")\n    except zipfile.BadZipFile:\n        print(\"File is not a zip or is corrupted.\")\n"
  },
  {
    "path": "comfy_cli/git_utils.py",
    "content": "import os\nimport subprocess\n\nfrom rich.console import Console\nfrom rich.panel import Panel\nfrom rich.text import Text\n\nfrom comfy_cli.command.github.pr_info import PRInfo\n\nconsole = Console()\n\n\ndef sanitize_for_local_branch(branch_name: str) -> str:\n    if not branch_name:\n        return \"unknown\"\n\n    sanitized = branch_name.replace(\"/\", \"-\")\n\n    while \"--\" in sanitized:\n        sanitized = sanitized.replace(\"--\", \"-\")\n\n    sanitized = sanitized.strip(\"-\")\n\n    return sanitized or \"unknown\"\n\n\ndef git_checkout_tag(repo_path: str, tag: str) -> bool:\n    \"\"\"\n    Checkout a specific Git tag in the given repository.\n\n    Skips the network ``git fetch --tags`` when the tag already exists locally.\n    This avoids a redundant round-trip on the happy path (the caller usually\n    just cloned the repo or just ran a fetch via the resolver) and lets offline\n    installs proceed when the tag is already cached. Only when the tag is\n    absent locally do we attempt to fetch — and a failed fetch in that case is\n    a real, unrecoverable error (``check=True`` surfaces it as before).\n\n    :param repo_path: Path to the Git repository\n    :param tag: The tag to checkout\n    :return: True if the checkout succeeds, False if any git command failed.\n    \"\"\"\n    original_dir = os.getcwd()\n    try:\n        # Change to the repository directory\n\n        os.chdir(repo_path)\n\n        # Skip the network fetch when the tag is already present locally.\n        tag_present_locally = (\n            subprocess.run(\n                [\"git\", \"rev-parse\", \"--verify\", f\"refs/tags/{tag}\"],\n                capture_output=True,\n                text=True,\n                check=False,\n            ).returncode\n            == 0\n        )\n        if not tag_present_locally:\n            subprocess.run([\"git\", \"fetch\", \"--tags\"], check=True, capture_output=True, text=True)\n\n        # Checkout the specified tag\n        subprocess.run([\"git\", \"checkout\", tag], check=True, capture_output=True, text=True)\n\n        console.print(f\"[bold green]Successfully checked out tag: [cyan]{tag}[/cyan][/bold green]\")\n\n        return True\n    except subprocess.CalledProcessError as e:\n        error_message = Text()\n        error_message.append(\"Git Checkout Error\", style=\"bold red on white\")\n        error_message.append(\"\\n\\nFailed to checkout tag: \", style=\"bold yellow\")\n        error_message.append(f\"[cyan]{tag}[/cyan]\")\n        error_message.append(\"\\n\\nError details:\", style=\"bold red\")\n        error_message.append(f\"\\n{str(e)}\", style=\"italic\")\n\n        if e.stderr:\n            error_message.append(\"\\n\\nError output:\", style=\"bold red\")\n            error_message.append(f\"\\n{e.stderr}\", style=\"italic yellow\")\n\n        console.print(\n            Panel(\n                error_message,\n                title=\"[bold white on red]Git Checkout Failed[/bold white on red]\",\n                border_style=\"red\",\n                expand=False,\n            )\n        )\n\n        return False\n    finally:\n        # Ensure we always return to the original directory\n        os.chdir(original_dir)\n\n\ndef checkout_pr(repo_path: str, pr_info: PRInfo) -> bool:\n    original_dir = os.getcwd()\n\n    try:\n        os.chdir(repo_path)\n\n        if pr_info.is_fork:\n            remote_name = f\"pr-{pr_info.number}-{pr_info.user}\"\n\n            result = subprocess.run([\"git\", \"remote\", \"get-url\", remote_name], capture_output=True, text=True)\n\n            if result.returncode != 0:\n                subprocess.run(\n                    [\"git\", \"remote\", \"add\", remote_name, pr_info.head_repo_url],\n                    check=True,\n                    capture_output=True,\n                    text=True,\n                )\n\n            subprocess.run(\n                [\"git\", \"fetch\", remote_name, pr_info.head_branch], check=True, capture_output=True, text=True\n            )\n\n            # fix: \"feature/add-support\" -> \"pr-123-feature-add-support\"\n            sanitized_branch = sanitize_for_local_branch(pr_info.head_branch)\n            local_branch = f\"pr-{pr_info.number}-{sanitized_branch}\"\n\n            subprocess.run(\n                [\"git\", \"checkout\", \"-B\", local_branch, f\"{remote_name}/{pr_info.head_branch}\"],\n                check=True,\n                capture_output=True,\n                text=True,\n            )\n\n        else:\n            subprocess.run([\"git\", \"fetch\", \"origin\", pr_info.head_branch], check=True, capture_output=True, text=True)\n\n            sanitized_branch = sanitize_for_local_branch(pr_info.head_branch)\n            local_branch = f\"pr-{pr_info.number}-{sanitized_branch}\"\n\n            subprocess.run(\n                [\"git\", \"checkout\", \"-B\", local_branch, f\"origin/{pr_info.head_branch}\"],\n                check=True,\n                capture_output=True,\n                text=True,\n            )\n\n        console.print(f\"[bold green]Successfully checked out PR #{pr_info.number}: {pr_info.title}[/bold green]\")\n        console.print(f\"[bold yellow]Local branch:[/bold yellow] {local_branch}\")\n        return True\n\n    except subprocess.CalledProcessError as e:\n        error_message = Text()\n        error_message.append(\"Git PR Checkout Error\", style=\"bold red on white\")\n        error_message.append(f\"\\n\\nFailed to checkout PR #{pr_info.number}\", style=\"bold yellow\")\n        error_message.append(f\"\\nTitle: {pr_info.title}\", style=\"italic\")\n        error_message.append(f\"\\nBranch: {pr_info.head_branch}\", style=\"italic\")\n\n        if e.stderr:\n            error_message.append(\"\\n\\nError output:\", style=\"bold red\")\n            error_message.append(f\"\\n{e.stderr}\", style=\"italic yellow\")\n\n        console.print(\n            Panel(\n                error_message,\n                title=\"[bold white on red]PR Checkout Failed[/bold white on red]\",\n                border_style=\"red\",\n                expand=False,\n            )\n        )\n        return False\n\n    finally:\n        os.chdir(original_dir)\n"
  },
  {
    "path": "comfy_cli/logging.py",
    "content": "\"\"\"\nThis module provides logging utilities for the CLI.\n\nNote: we could potentially change the logging library or the way we log messages in the future.\nTherefore, it's a good idea to encapsulate logging-related code in a separate module.\n\"\"\"\n\nimport logging\nimport os\n\n\ndef setup_logging():\n    # TODO: consider supporting different ways of outputting logs\n    # Note: by default, the log level is set to WARN\n    log_levels = {\n        \"DEBUG\": logging.DEBUG,\n        \"INFO\": logging.INFO,\n        \"WARNING\": logging.WARNING,\n        \"ERROR\": logging.ERROR,\n        \"CRITICAL\": logging.CRITICAL,\n    }\n    log_level_key = os.getenv(\"LOG_LEVEL\", \"ERROR\").upper()\n    logging.basicConfig(\n        level=log_levels.get(log_level_key, logging.WARNING),\n        format=\"%(asctime)s - %(name)s - %(levelname)s - %(message)s\",\n        datefmt=\"%Y-%m-%d %H:%M:%S\",\n    )\n\n\ndef debug(message):\n    logging.debug(message)\n\n\ndef info(message):\n    logging.info(message)\n\n\ndef warning(message):\n    logging.warning(message)\n\n\ndef error(message):\n    logging.error(message)\n    # TODO: consider tracking errors to Mixpanel as well.\n"
  },
  {
    "path": "comfy_cli/pr_cache.py",
    "content": "\"\"\"PR Cache Management for temporary PR testing.\n\nThis module provides functionality for caching built frontend PRs to enable\nquick switching between different PR versions without rebuilding.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport shutil\nfrom datetime import datetime, timedelta\nfrom pathlib import Path\n\nfrom rich import print as rprint\n\nfrom comfy_cli.config_manager import ConfigManager\n\n\nclass PRCache:\n    \"\"\"Manages cached PR builds for quick switching.\n\n    This class handles the caching of built frontend PRs, including:\n    - Cache directory management\n    - Cache validity checking with age limits\n    - Automatic cleanup of old/excess cache entries\n    - Human-readable cache information display\n    \"\"\"\n\n    # Default cache settings\n    DEFAULT_MAX_CACHE_AGE_DAYS = 7  # Cache entries older than this are considered stale\n    DEFAULT_MAX_CACHE_ITEMS = 10  # Maximum number of cached PRs to keep\n\n    def __init__(self) -> None:\n        \"\"\"Initialize PR cache with default settings.\"\"\"\n        self.cache_dir = Path(ConfigManager().get_config_path()) / \"pr-cache\"\n        self.cache_dir.mkdir(parents=True, exist_ok=True)\n        self.max_cache_age = timedelta(days=self.DEFAULT_MAX_CACHE_AGE_DAYS)\n        self.max_cache_items = self.DEFAULT_MAX_CACHE_ITEMS\n\n    def get_frontend_cache_path(self, pr_info) -> Path:\n        \"\"\"Get cache path for a frontend PR\"\"\"\n        # Use PR number and repo as cache key\n        cache_key = f\"{pr_info.user}-{pr_info.number}-{pr_info.head_branch}\"\n        # Sanitize for filesystem\n        cache_key = \"\".join(c if c.isalnum() or c in \"-_\" else \"_\" for c in cache_key)\n        return self.cache_dir / \"frontend\" / cache_key\n\n    def get_cache_info_path(self, cache_path: Path) -> Path:\n        \"\"\"Get path to cache info file\"\"\"\n        return cache_path / \".cache-info.json\"\n\n    def is_cache_valid(self, pr_info, cache_path: Path) -> bool:\n        \"\"\"Check if cached build is still valid\"\"\"\n        info_path = self.get_cache_info_path(cache_path)\n        if not info_path.exists():\n            return False\n\n        try:\n            with open(info_path, encoding=\"utf-8\") as file:\n                cache_info = json.load(file)\n\n            # Check if cache metadata matches\n            if not (\n                cache_info.get(\"pr_number\") == pr_info.number\n                and cache_info.get(\"head_branch\") == pr_info.head_branch\n                and cache_info.get(\"user\") == pr_info.user\n            ):\n                return False\n\n            # Check if cache is too old\n            cached_at = cache_info.get(\"cached_at\")\n            if cached_at:\n                cache_time = datetime.fromisoformat(cached_at)\n                if datetime.now() - cache_time > self.max_cache_age:\n                    return False\n\n            return True\n        except (json.JSONDecodeError, OSError):\n            return False\n\n    def save_cache_info(self, pr_info, cache_path: Path) -> None:\n        \"\"\"Save cache metadata.\"\"\"\n        info_path = self.get_cache_info_path(cache_path)\n        cache_info = {\n            \"pr_number\": pr_info.number,\n            \"pr_title\": pr_info.title,\n            \"user\": pr_info.user,\n            \"head_branch\": pr_info.head_branch,\n            \"head_repo_url\": pr_info.head_repo_url,\n            \"cached_at\": datetime.now().isoformat(),\n        }\n\n        with open(info_path, \"w\", encoding=\"utf-8\") as file:\n            json.dump(cache_info, file, indent=2)\n\n        # Enforce cache limits after saving new cache\n        self.enforce_cache_limits()\n\n    def get_cached_frontend_path(self, pr_info) -> Path | None:\n        \"\"\"Get path to cached frontend build if valid\"\"\"\n        cache_path = self.get_frontend_cache_path(pr_info)\n        dist_path = cache_path / \"repo\" / \"dist\"\n\n        if dist_path.exists() and self.is_cache_valid(pr_info, cache_path):\n            return dist_path\n        return None\n\n    def _load_cache_info(self, cache_dir: Path) -> dict | None:\n        \"\"\"Load cache info from a directory.\"\"\"\n        info_path = self.get_cache_info_path(cache_dir)\n        if not info_path.exists():\n            return None\n\n        try:\n            with open(info_path, encoding=\"utf-8\") as file:\n                return json.load(file)\n        except (json.JSONDecodeError, OSError):\n            return None\n\n    def _clean_specific_pr_cache(self, frontend_cache: Path, pr_number: int) -> None:\n        \"\"\"Clean cache for a specific PR number.\"\"\"\n        for cache_dir in frontend_cache.iterdir():\n            if not cache_dir.is_dir():\n                continue\n            info = self._load_cache_info(cache_dir)\n            if info and info.get(\"pr_number\") == pr_number:\n                rprint(f\"[yellow]Removing cache for PR #{pr_number}[/yellow]\")\n                shutil.rmtree(cache_dir)\n                break\n\n    def clean_frontend_cache(self, pr_number: int | None = None) -> None:\n        \"\"\"Clean frontend cache (specific PR or all).\"\"\"\n        frontend_cache = self.cache_dir / \"frontend\"\n        if not frontend_cache.exists():\n            return\n\n        if pr_number:\n            self._clean_specific_pr_cache(frontend_cache, pr_number)\n        else:\n            # Clean all\n            rprint(\"[yellow]Removing all frontend PR cache[/yellow]\")\n            shutil.rmtree(frontend_cache)\n\n    def _calculate_cache_size_mb(self, cache_dir: Path) -> float:\n        \"\"\"Calculate the size of a cache directory in MB.\"\"\"\n        total_size = sum(f.stat().st_size for f in cache_dir.rglob(\"*\") if f.is_file())\n        return total_size / (1024 * 1024)\n\n    def _get_cache_info_with_metadata(self, cache_dir: Path) -> dict | None:\n        \"\"\"Get cache info with additional metadata like path and size.\"\"\"\n        info = self._load_cache_info(cache_dir)\n        if info:\n            info[\"cache_path\"] = str(cache_dir)\n            info[\"size_mb\"] = self._calculate_cache_size_mb(cache_dir)\n        return info\n\n    def list_cached_frontends(self) -> list[dict]:\n        \"\"\"List all cached frontend PRs.\"\"\"\n        frontend_cache = self.cache_dir / \"frontend\"\n        if not frontend_cache.exists():\n            return []\n\n        cached_prs = []\n        for cache_dir in frontend_cache.iterdir():\n            if not cache_dir.is_dir():\n                continue\n            info = self._get_cache_info_with_metadata(cache_dir)\n            if info:\n                cached_prs.append(info)\n\n        return sorted(cached_prs, key=lambda x: x.get(\"cached_at\", \"\"), reverse=True)\n\n    def _is_cache_expired(self, cached_at: str) -> bool:\n        \"\"\"Check if a cache entry is expired based on its timestamp.\"\"\"\n        try:\n            cache_time = datetime.fromisoformat(cached_at)\n            return datetime.now() - cache_time > self.max_cache_age\n        except (ValueError, TypeError):\n            return True  # Consider invalid timestamps as expired\n\n    def _get_expired_items(self, cached_items: list[dict]) -> list[dict]:\n        \"\"\"Get list of expired cache items.\"\"\"\n        expired = []\n        for item in cached_items:\n            cached_at = item.get(\"cached_at\")\n            if cached_at and self._is_cache_expired(cached_at):\n                expired.append(item)\n        return expired\n\n    def _get_excess_items(self, cached_items: list[dict], expired_items: list[dict]) -> list[dict]:\n        \"\"\"Get list of items that exceed the maximum cache limit.\"\"\"\n        remaining_items = [item for item in cached_items if item not in expired_items]\n        if len(remaining_items) > self.max_cache_items:\n            # Return oldest items that exceed the limit\n            return remaining_items[self.max_cache_items :]\n        return []\n\n    def _remove_cache_item(self, item: dict) -> None:\n        \"\"\"Remove a single cache item.\"\"\"\n        cache_path = Path(item[\"cache_path\"])\n        if cache_path.exists():\n            pr_info = f\"PR #{item.get('pr_number', '?')} ({item.get('pr_title', 'Unknown')[:30]}...)\"\n            rprint(f\"[yellow]Removing old cache: {pr_info}[/yellow]\")\n            shutil.rmtree(cache_path)\n\n    def enforce_cache_limits(self) -> None:\n        \"\"\"Remove old and excess cache entries to maintain limits.\"\"\"\n        cached_items = self.list_cached_frontends()\n\n        # Get items to remove\n        expired_items = self._get_expired_items(cached_items)\n        excess_items = self._get_excess_items(cached_items, expired_items)\n\n        # Remove all identified items\n        items_to_remove = expired_items + excess_items\n        for item in items_to_remove:\n            self._remove_cache_item(item)\n\n    def get_cache_age(self, cached_at: str) -> str:\n        \"\"\"Get human-readable age of cache entry\"\"\"\n        try:\n            cache_time = datetime.fromisoformat(cached_at)\n            age = datetime.now() - cache_time\n\n            if age.days > 0:\n                return f\"{age.days} day{'s' if age.days != 1 else ''} ago\"\n            if age.seconds > 3600:\n                hours = age.seconds // 3600\n                return f\"{hours} hour{'s' if hours != 1 else ''} ago\"\n            if age.seconds > 60:\n                minutes = age.seconds // 60\n                return f\"{minutes} minute{'s' if minutes != 1 else ''} ago\"\n            return \"just now\"\n        except (json.JSONDecodeError, OSError):\n            return \"unknown\"\n"
  },
  {
    "path": "comfy_cli/registry/__init__.py",
    "content": "from .api import RegistryAPI\nfrom .config_parser import extract_node_configuration, initialize_project_config\nfrom .types import Node, NodeVersion, PublishNodeVersionResponse, PyProjectConfig\n\n__all__ = [\n    \"RegistryAPI\",\n    \"extract_node_configuration\",\n    \"PyProjectConfig\",\n    \"PublishNodeVersionResponse\",\n    \"NodeVersion\",\n    \"Node\",\n    \"initialize_project_config\",\n]\n"
  },
  {
    "path": "comfy_cli/registry/api.py",
    "content": "import json\nimport logging\nimport os\n\nimport requests\n\n# Reduced global imports from comfy_cli.registry\nfrom comfy_cli.registry.types import (\n    License,\n    Node,\n    NodeVersion,\n    PublishNodeVersionResponse,\n    PyProjectConfig,\n)\n\n\nclass RegistryAPI:\n    def __init__(self):\n        self.base_url = self.determine_base_url()\n\n    def determine_base_url(self):\n        env = os.getenv(\"ENVIRONMENT\")\n        if env == \"dev\":\n            return \"http://localhost:8080\"\n        elif env == \"staging\":\n            return \"https://stagingapi.comfy.org\"\n        else:\n            return \"https://api.comfy.org\"\n\n    def publish_node_version(self, node_config: PyProjectConfig, token) -> PublishNodeVersionResponse:\n        \"\"\"\n        Publishes a new version of a node.\n\n        Args:\n          node_config (PyProjectConfig): The node configuration.\n          token (str): The token to authenticate with the API server.\n\n        Returns:\n        PublishNodeVersionResponse: The response object from the API server.\n        \"\"\"\n        # Local import to prevent circular dependency\n        if not node_config.tool_comfy.publisher_id:\n            raise Exception(\"Publisher ID is required in pyproject.toml to publish a node version\")\n\n        if not node_config.project.name:\n            raise Exception(\"Project name is required in pyproject.toml to publish a node version\")\n        license_json = serialize_license(node_config.project.license)\n        request_body = {\n            \"personal_access_token\": token,\n            \"node\": {\n                \"id\": node_config.project.name,\n                \"description\": node_config.project.description,\n                \"icon\": node_config.tool_comfy.icon,\n                \"name\": node_config.tool_comfy.display_name,\n                \"license\": license_json,\n                \"repository\": node_config.project.urls.repository,\n                \"banner_url\": node_config.tool_comfy.banner_url,\n                \"supported_os\": node_config.project.supported_os,\n                \"supported_accelerators\": node_config.project.supported_accelerators,\n                \"supported_comfyui_version\": node_config.project.supported_comfyui_version,\n                \"supported_comfyui_frontend_version\": node_config.project.supported_comfyui_frontend_version,\n            },\n            \"node_version\": {\n                \"version\": node_config.project.version,\n                \"dependencies\": node_config.project.dependencies,\n                \"supported_os\": node_config.project.supported_os,\n                \"supported_accelerators\": node_config.project.supported_accelerators,\n                \"supported_comfyui_version\": node_config.project.supported_comfyui_version,\n                \"supported_comfyui_frontend_version\": node_config.project.supported_comfyui_frontend_version,\n            },\n        }\n        print(request_body)\n        url = f\"{self.base_url}/publishers/{node_config.tool_comfy.publisher_id}/nodes/{node_config.project.name}/versions\"\n        headers = {\"Content-Type\": \"application/json\"}\n        body = request_body\n\n        response = requests.post(url, headers=headers, data=json.dumps(body))\n\n        if response.status_code == 201:\n            data = response.json()\n            return PublishNodeVersionResponse(\n                node_version=map_node_version(data[\"node_version\"]),\n                signedUrl=data[\"signedUrl\"],\n            )\n        else:\n            raise Exception(f\"Failed to publish node version: {response.status_code} {response.text}\")\n\n    def list_all_nodes(self):\n        \"\"\"\n        Retrieves a list of all nodes and maps them to Node dataclass instances.\n\n        Returns:\n          list: A list of Node instances.\n        \"\"\"\n        url = f\"{self.base_url}/nodes\"\n        response = requests.get(url)\n        if response.status_code == 200:\n            raw_nodes = response.json()[\"nodes\"]\n            return [map_node_to_node_class(node) for node in raw_nodes]\n        else:\n            raise Exception(f\"Failed to retrieve nodes: {response.status_code} - {response.text}\")\n\n    def install_node(self, node_id, version=None):\n        \"\"\"\n        Retrieves the node version for installation.\n\n        Args:\n          node_id (str): The unique identifier of the node.\n          version (str, optional): Specific version of the node to retrieve. If omitted, the latest version is returned.\n\n        Returns:\n          NodeVersion: Node version data or error message.\n        \"\"\"\n        if version is None:\n            url = f\"{self.base_url}/nodes/{node_id}/install\"\n        else:\n            url = f\"{self.base_url}/nodes/{node_id}/install?version={version}\"\n\n        response = requests.get(url)\n        if response.status_code == 200:\n            # Convert the API response to a NodeVersion object\n            logging.debug(f\"RegistryAPI install_node response: {response.json()}\")\n            return map_node_version(response.json())\n        else:\n            raise Exception(f\"Failed to install node: {response.status_code} - {response.text}\")\n\n\ndef map_node_version(api_node_version):\n    \"\"\"\n    Maps node version data from API response to NodeVersion dataclass.\n\n    Args:\n        api_data (dict): The 'node_version' part of the API response.\n\n    Returns:\n        NodeVersion: An instance of NodeVersion dataclass populated with data from the API.\n    \"\"\"\n    return NodeVersion(\n        changelog=api_node_version.get(\"changelog\", \"\"),  # Provide a default value if 'changelog' is missing\n        dependencies=api_node_version.get(\n            \"dependencies\", []\n        ),  # Provide a default empty list if 'dependencies' is missing\n        deprecated=api_node_version.get(\"deprecated\", False),  # Assume False if 'deprecated' is not specified\n        id=api_node_version[\"id\"],  # 'id' should be mandatory; raise KeyError if missing\n        version=api_node_version[\"version\"],  # 'version' should be mandatory; raise KeyError if missing\n        download_url=api_node_version.get(\"downloadUrl\", \"\"),  # Provide a default value if 'downloadUrl' is missing\n    )\n\n\ndef map_node_to_node_class(api_node_data):\n    \"\"\"\n    Maps node data from API response to Node dataclass.\n\n    Args:\n        api_node_data (dict): The node data from the API.\n\n    Returns:\n        Node: An instance of Node dataclass populated with API data.\n    \"\"\"\n    return Node(\n        id=api_node_data[\"id\"],\n        name=api_node_data[\"name\"],\n        description=api_node_data[\"description\"],\n        author=api_node_data.get(\"author\"),\n        license=api_node_data.get(\"license\"),\n        icon=api_node_data.get(\"icon\"),\n        repository=api_node_data.get(\"repository\"),\n        tags=api_node_data.get(\"tags\", []),\n        latest_version=(\n            map_node_version(api_node_data[\"latest_version\"]) if \"latest_version\" in api_node_data else None\n        ),\n    )\n\n\ndef serialize_license(license: License) -> str:\n    if license.file:\n        return json.dumps({\"file\": license.file})\n    if license.text:\n        return json.dumps({\"text\": license.text})\n    return \"{}\"\n"
  },
  {
    "path": "comfy_cli/registry/config_parser.py",
    "content": "import os\nimport pathlib\nimport re\nimport subprocess\nfrom urllib.parse import urlparse, urlunparse\n\nimport tomlkit\nimport tomlkit.exceptions\nimport typer\n\nfrom comfy_cli import ui\nfrom comfy_cli.registry.types import (\n    ComfyConfig,\n    License,\n    Model,\n    ProjectConfig,\n    PyProjectConfig,\n    URLs,\n)\n\n# Mirrors pip's requirements-file comment rule: `#` only starts a comment when\n# preceded by whitespace, so VCS URL fragments (`#subdirectory=`, `#egg=`) and\n# direct-URL hashes (`#sha256=`) survive.\n_inline_comment_re: re.Pattern[str] = re.compile(r\"(^|\\s+)#.*$\")\n\n# For `dynamic = [\"version\"]`: match a top-level `__version__` or `VERSION` assignment in a source file. Anchored\n# to start-of-line (MULTILINE) so single-line comments are skipped. Horizontal whitespace only — no cross-line\n# matching. Supports an optional PEP 526 type annotation. Straight quotes only. Backslash is excluded from the\n# value class — escape sequences (`\\n`, `\\t`, `\\\"`, ...) cause the regex to fail to match, surfacing as a\n# \"could not find\" warning rather than being silently misinterpreted. PEP 440 versions are ASCII-only so this\n# is a clean fail-closed contract; users with auto-generated `__version__` containing escapes must clean up\n# their source. The single-alternative character class also makes catastrophic backtracking impossible.\n#\n# Recommended user layout: a dedicated `_version.py` / `__version__.py` (NOT the package's `__init__.py`), so\n# nothing in the file — module docstrings, assignments referenced in text, etc. — can collide with this regex.\n# This matches hatch/setuptools convention for dynamic-version source files.\n_VERSION_RE: re.Pattern[str] = re.compile(\n    r\"\"\"^(?P<name>__version__|VERSION)\n        (?:[\\t ]*:[\\t ]*[^=\\n]+)?\n        [\\t ]*=[\\t ]*\n        (?:\"(?P<dq>[^\"\\\\\\n]*)\"|'(?P<sq>[^'\\\\\\n]*)')\n        \"\"\",\n    re.MULTILINE | re.VERBOSE,\n)\n\n\ndef create_comfynode_config():\n    # Create the initial structure of the TOML document\n    document = tomlkit.document()\n\n    project = tomlkit.table()\n    project[\"name\"] = \"\"\n    project[\"description\"] = \"\"\n    project[\"version\"] = \"1.0.0\"\n    project[\"dependencies\"] = tomlkit.aot()\n    project[\"license\"] = \"MIT\"\n\n    urls = tomlkit.table()\n    urls[\"Repository\"] = \"\"\n\n    project.add(\"urls\", urls)\n    document.add(\"project\", project)\n\n    # Create the tool table\n    tool = tomlkit.table()\n    document.add(tomlkit.comment(\" Used by Comfy Registry https://registry.comfy.org\"))\n\n    comfy = tomlkit.table()\n    comfy[\"PublisherId\"] = \"\"\n    comfy[\"DisplayName\"] = \"ComfyUI-AIT\"\n    comfy[\"Icon\"] = \"\"\n    comfy[\"includes\"] = tomlkit.array()\n\n    # Add uncommentable hint for ComfyUI version compatibility, below of \"[tool.comfy].includes\" field.\n    comfy[\"includes\"].comment(\"\"\"\n# \"requires-comfyui\" = \">=1.0.0\"  # ComfyUI version compatibility\n\"\"\")\n\n    tool.add(\"comfy\", comfy)\n    document.add(\"tool\", tool)\n\n    # Add the default model\n    # models = tomlkit.array()\n    # model = tomlkit.inline_table()\n    # model[\"location\"] = \"/checkpoints/model.safetensor\"\n    # model[\"model_url\"] = \"https://example.com/model.zip\"\n    # models.append(model)\n    # comfy[\"Models\"] = models\n\n    # Write the TOML document to a file\n    try:\n        with open(\"pyproject.toml\", \"w\") as toml_file:\n            toml_file.write(tomlkit.dumps(document))\n    except OSError as e:\n        raise Exception(\"Failed to write 'pyproject.toml'\") from e\n\n\ndef sanitize_node_name(name: str) -> str:\n    \"\"\"Remove common ComfyUI-related prefixes from a string.\n\n    Args:\n        name: The string to process\n\n    Returns:\n        The string with any ComfyUI-related prefix removed\n    \"\"\"\n    name = name.lower()\n    prefixes = [\n        \"comfyui-\",\n        \"comfyui_\",\n        \"comfy-\",\n        \"comfy_\",\n        \"comfy\",\n        \"comfyui\",\n    ]\n\n    for prefix in prefixes:\n        name = name.removeprefix(prefix)\n    return name\n\n\ndef validate_and_extract_os_classifiers(classifiers: list) -> list:\n    os_classifiers = [c for c in classifiers if c.startswith(\"Operating System :: \")]\n    if not os_classifiers:\n        return []\n\n    os_values = [c[len(\"Operating System :: \") :] for c in os_classifiers]\n    valid_os_prefixes = {\"Microsoft\", \"POSIX\", \"MacOS\", \"OS Independent\"}\n\n    for os_value in os_values:\n        if not any(os_value.startswith(prefix) for prefix in valid_os_prefixes):\n            typer.echo(\n                'Warning: Invalid Operating System classifier found. Operating System classifiers must start with one of: \"Microsoft\", \"POSIX\", \"MacOS\", \"OS Independent\". '\n                'Examples: \"Operating System :: Microsoft :: Windows\", \"Operating System :: POSIX :: Linux\", \"Operating System :: MacOS\", \"Operating System :: OS Independent\". '\n                \"No OS information will be populated.\"\n            )\n            return []\n\n    return os_values\n\n\ndef validate_and_extract_accelerator_classifiers(classifiers: list) -> list:\n    accelerator_classifiers = [c for c in classifiers if c.startswith(\"Environment ::\")]\n    if not accelerator_classifiers:\n        return []\n\n    accelerator_values = [c[len(\"Environment :: \") :] for c in accelerator_classifiers]\n\n    valid_accelerators = {\n        \"GPU :: NVIDIA CUDA\",\n        \"GPU :: AMD ROCm\",\n        \"GPU :: Intel Arc\",\n        \"NPU :: Huawei Ascend\",\n        \"GPU :: Apple Metal\",\n    }\n\n    for accelerator_value in accelerator_values:\n        if accelerator_value not in valid_accelerators:\n            typer.echo(\n                \"Warning: Invalid Environment classifier found. Environment classifiers must be one of: \"\n                '\"Environment :: GPU :: NVIDIA CUDA\", \"Environment :: GPU :: AMD ROCm\", \"Environment :: GPU :: Intel Arc\", '\n                '\"Environment :: NPU :: Huawei Ascend\", \"Environment :: GPU :: Apple Metal\". '\n                \"No accelerator information will be populated.\"\n            )\n            return []\n\n    return accelerator_values\n\n\ndef validate_version(version: str, field_name: str) -> str:\n    if not version:\n        return version\n\n    version_pattern = r\"^(?:(==|>=|<=|!=|~=|>|<|<>|=)\\s*)?(\\d+\\.\\d+\\.\\d+(?:-[a-zA-Z0-9]+)?)?$\"\n\n    version_parts = [part.strip() for part in version.split(\",\")]\n    for part in version_parts:\n        if not re.match(version_pattern, part):\n            typer.echo(\n                f'Warning: Invalid {field_name} format: \"{version}\". '\n                f\"Each version part must follow the pattern: [operator][version] where operator is optional (==, >=, <=, !=, ~=, >, <, <>, =) \"\n                f\"and version is in format major.minor.patch[-suffix]. \"\n                f\"Multiple versions can be comma-separated. \"\n                f'Examples: \">=1.0.0\", \"==2.1.0-beta\", \"1.5.2\", \">=1.0.0,<2.0.0\". '\n                f\"No {field_name} will be populated.\"\n            )\n            return \"\"\n\n    return version\n\n\ndef _strip_url_credentials(url: str) -> str:\n    parsed = urlparse(url)\n    if parsed.scheme in (\"http\", \"https\") and (parsed.username or parsed.password):\n        netloc = parsed.hostname or \"\"\n        if \":\" in netloc:\n            netloc = f\"[{netloc}]\"\n        if parsed.port:\n            netloc += f\":{parsed.port}\"\n        return urlunparse(parsed._replace(netloc=netloc))\n    return url\n\n\ndef initialize_project_config():\n    create_comfynode_config()\n\n    with open(\"pyproject.toml\") as file:\n        document = tomlkit.parse(file.read())\n\n    # Get the current git remote URL\n    try:\n        git_remote_url = subprocess.check_output([\"git\", \"remote\", \"get-url\", \"origin\"]).decode().strip()\n        git_remote_url = _strip_url_credentials(git_remote_url)\n    except subprocess.CalledProcessError as e:\n        raise Exception(\"Could not retrieve Git remote URL. Are you in a Git repository?\") from e\n\n    # Convert SSH URL to HTTPS if needed\n    if git_remote_url.startswith(\"git@github.com:\"):\n        git_remote_url = git_remote_url.replace(\"git@github.com:\", \"https://github.com/\")\n\n    # Ensure the URL ends with `.git` and remove it to obtain the plain URL\n    repo_name = git_remote_url.rsplit(\"/\", maxsplit=1)[-1].replace(\".git\", \"\")\n    git_remote_url = git_remote_url.replace(\".git\", \"\")\n\n    project = document.get(\"project\", tomlkit.table())\n    urls = project.get(\"urls\", tomlkit.table())\n    urls[\"Repository\"] = git_remote_url\n    urls[\"Documentation\"] = git_remote_url + \"/wiki\"\n    urls[\"Bug Tracker\"] = git_remote_url + \"/issues\"\n\n    project[\"urls\"] = urls\n    project[\"name\"] = sanitize_node_name(repo_name)\n    project[\"description\"] = \"\"\n    project[\"version\"] = \"1.0.0\"\n\n    # Use PEP 639 SPDX license identifier\n    project[\"license\"] = \"MIT\"\n\n    # [project].classifiers Classifiers uncommentable hint for OS/GPU support\n    # Attach classifiers comments to the project, below of \"license\" field.\n    # will generate a comment like this:\n    #\n    # [project]\n    # ...\n    # license = \"MIT\"\n    # # classifiers = [\n    # #     # For OS-independent nodes (works on all operating systems)\n    # ...\n\n    project[\"license\"].comment(\"\"\"\n# classifiers = [\n#     # For OS-independent nodes (works on all operating systems)\n#     \"Operating System :: OS Independent\",\n#\n#     # OR for OS-specific nodes, specify the supported systems:\n#     \"Operating System :: Microsoft :: Windows\",  # Windows specific\n#     \"Operating System :: POSIX :: Linux\",  # Linux specific\n#     \"Operating System :: MacOS\",  # macOS specific\n#\n#     # GPU Accelerator support. Pick the ones that are supported by your extension.\n#     \"Environment :: GPU :: NVIDIA CUDA\",    # NVIDIA CUDA support\n#     \"Environment :: GPU :: AMD ROCm\",       # AMD ROCm support\n#     \"Environment :: GPU :: Intel Arc\",      # Intel Arc support\n#     \"Environment :: NPU :: Huawei Ascend\",  # Huawei Ascend support\n#     \"Environment :: GPU :: Apple Metal\",    # Apple Metal support\n# ]\n\"\"\")\n\n    tool = document.get(\"tool\", tomlkit.table())\n    comfy = tool.get(\"comfy\", tomlkit.table())\n    comfy[\"DisplayName\"] = repo_name\n    tool[\"comfy\"] = comfy\n    document[\"tool\"] = tool\n\n    # Handle dependencies\n    if os.path.exists(\"requirements.txt\"):\n        with open(\"requirements.txt\") as req_file:\n            dependencies: list[str] = []\n            for raw in req_file:\n                # Strip inline/full-line comments, then skip pip-requirements-file\n                # options (-r, -e, -c, --index-url, ...) which are not valid\n                # PEP 508 deps and would break downstream build tooling.\n                line = _inline_comment_re.sub(\"\", raw).strip()\n                if not line:\n                    continue\n                if line.startswith(\"-\"):\n                    print(\n                        f\"Warning: skipping pip-only option from requirements.txt (not valid as PEP 508 dep): {line!r}\"\n                    )\n                    continue\n                dependencies.append(line)\n        project[\"dependencies\"] = dependencies\n    else:\n        print(\"Warning: 'requirements.txt' not found. No dependencies will be added.\")\n\n    # Write the updated config to a new file in the current directory\n    try:\n        with open(\"pyproject.toml\", \"w\") as toml_file:\n            toml_file.write(tomlkit.dumps(document))\n        print(\"pyproject.toml has been created successfully in the current directory.\")\n    except OSError as e:\n        raise OSError(\"Failed to write 'pyproject.toml'\") from e\n\n\ndef _resolve_dynamic_version(pyproject_dir: pathlib.Path, rel_path: str) -> str:\n    \"\"\"Read a version from a source file referenced by `[tool.comfy.version].path`.\n\n    No Python execution — just text I/O and a regex, matching the contract\n    agreed in issue #294. Returns empty string on any failure and emits a\n    user-visible warning so scanning contexts degrade gracefully.\n    \"\"\"\n    # Reject paths that are absolute under either POSIX or Windows rules —\n    # `pathlib.Path.is_absolute()` alone is OS-specific (e.g., `/etc/foo` is\n    # not considered absolute on Windows because it has no drive), and we\n    # want identical rejection behavior regardless of the host OS.\n    if pathlib.PurePosixPath(rel_path).is_absolute() or pathlib.PureWindowsPath(rel_path).is_absolute():\n        typer.echo(\n            f\"Warning: `[tool.comfy.version].path` must be relative to pyproject.toml \"\n            f\"(got `{rel_path}`). No version will be populated.\"\n        )\n        return \"\"\n    path_obj = pathlib.Path(rel_path)\n\n    pyproject_dir = pyproject_dir.resolve()\n    resolved = (pyproject_dir / path_obj).resolve()\n    try:\n        resolved.relative_to(pyproject_dir)\n    except ValueError:\n        typer.echo(\n            f\"Warning: `[tool.comfy.version].path` must point inside the project directory \"\n            f\"(got `{rel_path}`). No version will be populated.\"\n        )\n        return \"\"\n\n    try:\n        # `utf-8-sig` transparently strips a leading BOM — some Windows editors\n        # add one, and it would defeat the `^__version__` anchor otherwise.\n        text = resolved.read_text(encoding=\"utf-8-sig\")\n    except (OSError, UnicodeDecodeError) as e:\n        typer.echo(f\"Warning: could not read version file `{rel_path}`: {e}. No version will be populated.\")\n        return \"\"\n\n    match = _VERSION_RE.search(text)\n    if not match:\n        typer.echo(\n            f\"Warning: could not find `__version__` or `VERSION` in `{rel_path}`. \"\n            f'The version file must contain a line like `__version__ = \"1.2.3\"`. '\n            f\"No version will be populated.\"\n        )\n        return \"\"\n\n    # Exactly one of `dq` / `sq` was consumed by the regex. An empty capture\n    # (`\"\"` / `''`) is a valid match the regex accepts; if followed by another\n    # quote the concat check below intercepts it (this also covers the\n    # triple-quoted `\"\"\"...\"\"\"` case), otherwise we return \"\" and the\n    # publish-layer guard surfaces it.\n    raw = match.group(\"dq\") if match.group(\"dq\") is not None else match.group(\"sq\")\n\n    # Python concatenates adjacent string literals: `__version__ = \"1.\" \"2.3\"`\n    # (with or without whitespace between, quote styles freely mixed) evaluates\n    # to \"1.2.3\". The regex captures only the first literal, so silently\n    # returning `\"1.\"` would POST a wrong version. Look ahead on the same line:\n    # if the first non-whitespace char is a quote, reject the concatenation.\n    # `;` (statement separator) and `#` (comment) are preserved because neither\n    # starts with a quote.\n    rest_of_line = text[match.end() :].split(\"\\n\", 1)[0]\n    stripped_rest = rest_of_line.lstrip(\" \\t\")\n    if stripped_rest and stripped_rest[0] in ('\"', \"'\"):\n        typer.echo(\n            f\"Warning: `{match.group('name')}` in `{rel_path}` uses adjacent-string-literal \"\n            f\"concatenation, which is not supported. Use a single assignment like \"\n            f'`{match.group(\"name\")} = \"1.2.3\"`. No version will be populated.'\n        )\n        return \"\"\n\n    return raw.strip()\n\n\ndef _parse_dynamic_fields(project_data) -> list[str]:\n    \"\"\"Return the `project.dynamic` field as a list of strings.\n\n    Warns and returns `[]` if `dynamic` is present but has the wrong shape\n    (e.g. a scalar string — a common PEP 621 misconfiguration).\n    \"\"\"\n    dynamic_raw = project_data.get(\"dynamic\", [])\n    # tomlkit.Array inherits from list, so valid arrays (including empty `[]`)\n    # pass through. Everything else is a misconfiguration.\n    if not isinstance(dynamic_raw, (list, tuple)):\n        typer.echo(\n            \"Warning: `project.dynamic` must be an array of strings. \"\n            'Use `dynamic = [\"version\"]` instead. '\n            \"No dynamic fields will be honored.\"\n        )\n        return []\n    return [str(d) for d in dynamic_raw]\n\n\ndef _extract_version(project_data, comfy_data, pyproject_dir: pathlib.Path) -> str:\n    \"\"\"Return the project version, honoring PEP 621 `dynamic = [\"version\"]`.\n\n    - Static `project.version` wins if present.\n    - If absent and `\"version\"` is in `project.dynamic`, resolve via\n      `[tool.comfy.version].path` (text-read + regex, no Python execution).\n    - Otherwise return empty (existing behavior).\n    \"\"\"\n    static_version = project_data.get(\"version\", \"\")\n    dynamic_fields = _parse_dynamic_fields(project_data)\n    # Type-check runs BEFORE the truthy check so falsy non-strings (`version = 0`,\n    # `version = 0.0`, `version = false`, `version = []`, `version = {}`) produce\n    # the same named \"must be a string\" warning as truthy non-strings (`version = 1`,\n    # `version = [\"1\",\"2\"]`, `version = { path = \"_v.py\" }`). With the order reversed,\n    # they would silently fall through to the dynamic branch and the user would\n    # only see the downstream \"project version is empty\" error at publish time.\n    if not isinstance(static_version, str):\n        typer.echo(\"Warning: `project.version` must be a string. No version will be populated.\")\n        return \"\"\n    if static_version:\n        # Strip so `version = \"  1.0.0  \"` doesn't get POSTed with surrounding\n        # whitespace. A whitespace-only `version = \"   \"` becomes \"\" and the\n        # publish-layer guard surfaces it as \"project version is empty\".\n        return static_version.strip()\n\n    if \"version\" not in dynamic_fields:\n        return \"\"\n\n    version_cfg = comfy_data.get(\"version\")\n    if version_cfg is None:\n        typer.echo(\n            'Warning: `dynamic = [\"version\"]` declared but `[tool.comfy.version].path` is not set. '\n            \"See https://docs.comfy.org/registry/specifications for dynamic-version setup. \"\n            \"No version will be populated.\"\n        )\n        return \"\"\n    if not isinstance(version_cfg, dict):\n        # A non-table value under `[tool.comfy].version` — the user likely\n        # wrote `version = \"x\"` scalar (or any other type) instead of a nested\n        # table.\n        typer.echo(\n            \"Warning: `[tool.comfy].version` must be a table with a `path` key. \"\n            'Use `[tool.comfy.version]` with `path = \"...\"` instead. '\n            \"No version will be populated.\"\n        )\n        return \"\"\n    # Order matters: check type BEFORE falsy-ness so that `path = 0` / `false`\n    # / `[]` / `{}` produce a type warning, not a misleading \"not set\" warning.\n    path_value = version_cfg.get(\"path\")\n    if path_value is not None and not isinstance(path_value, str):\n        typer.echo(\"Warning: `[tool.comfy.version].path` must be a string. No version will be populated.\")\n        return \"\"\n    if not path_value:\n        typer.echo(\n            \"Warning: `[tool.comfy.version].path` is not set. \"\n            \"See https://docs.comfy.org/registry/specifications for dynamic-version setup. \"\n            \"No version will be populated.\"\n        )\n        return \"\"\n\n    return _resolve_dynamic_version(pyproject_dir, path_value)\n\n\ndef extract_node_configuration(\n    path: str = os.path.join(os.getcwd(), \"pyproject.toml\"),\n) -> PyProjectConfig | None:\n    if not os.path.isfile(path):\n        ui.display_error_message(\"No pyproject.toml file found in the current directory.\")\n        return None\n\n    try:\n        # `utf-8-sig` strips a leading BOM if present — Windows editors sometimes\n        # write one, and tomlkit would otherwise report `Empty key at line 1 col 0`.\n        # `UnicodeDecodeError` must be in the except tuple: it is a `ValueError`,\n        # not an `OSError`, and would otherwise escape and crash the caller.\n        with open(path, encoding=\"utf-8-sig\") as file:\n            data = tomlkit.load(file)\n    except (OSError, UnicodeDecodeError, tomlkit.exceptions.TOMLKitError) as e:\n        ui.display_error_message(f\"Could not parse `{path}`: {e}\")\n        return None\n\n    project_data = data.get(\"project\", {})\n    if not isinstance(project_data, dict):\n        # Degenerate TOML like `project = \"hello\"` at the root. Keep scanning\n        # contexts alive by treating it as \"no project metadata\".\n        typer.echo(\"Warning: `project` in pyproject.toml must be a table. Using defaults.\")\n        project_data = {}\n    urls_data = project_data.get(\"urls\", {})\n    if not isinstance(urls_data, dict):\n        urls_data = {}\n    tool_data = data.get(\"tool\", {})\n    comfy_data = tool_data.get(\"comfy\", {}) if isinstance(tool_data, dict) else {}\n    if not isinstance(comfy_data, dict):\n        comfy_data = {}\n\n    dependencies = project_data.get(\"dependencies\", [])\n    supported_comfyui_frontend_version = \"\"\n    for dep in dependencies:\n        if isinstance(dep, str) and dep.startswith(\"comfyui-frontend-package\"):\n            supported_comfyui_frontend_version = dep.removeprefix(\"comfyui-frontend-package\")\n            break\n\n    # Remove the ComfyUI-frontend dependency from the dependencies list\n    dependencies = [\n        dep for dep in dependencies if not (isinstance(dep, str) and dep.startswith(\"comfyui-frontend-package\"))\n    ]\n\n    supported_comfyui_version = comfy_data.get(\"requires-comfyui\", \"\")\n\n    classifiers = project_data.get(\"classifiers\", [])\n    supported_os = validate_and_extract_os_classifiers(classifiers)\n    supported_accelerators = validate_and_extract_accelerator_classifiers(classifiers)\n    supported_comfyui_version = validate_version(supported_comfyui_version, \"requires-comfyui\")\n    supported_comfyui_frontend_version = validate_version(\n        supported_comfyui_frontend_version, \"comfyui-frontend-package\"\n    )\n\n    license_data = project_data.get(\"license\", {})\n    if isinstance(license_data, str):\n        license = License(text=license_data)\n    elif isinstance(license_data, dict):\n        if \"file\" in license_data or \"text\" in license_data:\n            license = License(file=license_data.get(\"file\", \"\"), text=license_data.get(\"text\", \"\"))\n        else:\n            typer.echo(\n                '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.'\n            )\n            license = License()\n    else:\n        license = License()\n        typer.echo(\n            '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.'\n        )\n\n    pyproject_dir = pathlib.Path(path).parent\n    version = _extract_version(project_data, comfy_data, pyproject_dir)\n\n    project = ProjectConfig(\n        name=project_data.get(\"name\", \"\"),\n        description=project_data.get(\"description\", \"\"),\n        version=version,\n        requires_python=project_data.get(\"requires-python\", \"\"),\n        dependencies=dependencies,\n        license=license,\n        urls=URLs(\n            homepage=urls_data.get(\"Homepage\", \"\"),\n            documentation=urls_data.get(\"Documentation\", \"\"),\n            repository=urls_data.get(\"Repository\", \"\"),\n            issues=urls_data.get(\"Issues\", \"\"),\n        ),\n        supported_os=supported_os,\n        supported_accelerators=supported_accelerators,\n        supported_comfyui_version=supported_comfyui_version,\n        supported_comfyui_frontend_version=supported_comfyui_frontend_version,\n    )\n\n    comfy = ComfyConfig(\n        publisher_id=comfy_data.get(\"PublisherId\", \"\"),\n        display_name=comfy_data.get(\"DisplayName\", \"\"),\n        icon=comfy_data.get(\"Icon\", \"\"),\n        models=[Model(location=m[\"location\"], model_url=m[\"model_url\"]) for m in comfy_data.get(\"Models\", [])],\n        includes=comfy_data.get(\"includes\", []),\n        banner_url=comfy_data.get(\"Banner\", \"\"),\n        web=comfy_data.get(\"web\", \"\"),\n    )\n\n    return PyProjectConfig(project=project, tool_comfy=comfy)\n"
  },
  {
    "path": "comfy_cli/registry/types.py",
    "content": "from dataclasses import dataclass, field\n\n\n@dataclass\nclass NodeVersion:\n    changelog: str\n    dependencies: list[str]\n    deprecated: bool\n    id: str\n    version: str\n    download_url: str\n\n\n@dataclass\nclass Node:\n    id: str\n    name: str\n    description: str\n    author: str | None = None\n    license: str | None = None\n    icon: str | None = None\n    repository: str | None = None\n    tags: list[str] = field(default_factory=list)\n    latest_version: NodeVersion | None = None\n\n\n@dataclass\nclass PublishNodeVersionResponse:\n    node_version: NodeVersion\n    signedUrl: str\n\n\n@dataclass\nclass URLs:\n    homepage: str = \"\"\n    documentation: str = \"\"\n    repository: str = \"\"\n    issues: str = \"\"\n\n\n@dataclass\nclass Model:\n    location: str\n    model_url: str\n\n\n@dataclass\nclass ComfyConfig:\n    publisher_id: str = \"\"\n    display_name: str = \"\"\n    icon: str = \"\"\n    models: list[Model] = field(default_factory=list)\n    includes: list[str] = field(default_factory=list)\n    banner_url: str = \"\"\n    web: str | None = None\n\n\n@dataclass\nclass License:\n    file: str = \"\"\n    text: str = \"\"\n\n\n@dataclass\nclass ProjectConfig:\n    name: str = \"\"\n    description: str = \"\"\n    version: str = \"1.0.0\"\n    requires_python: str = \">= 3.9\"\n    dependencies: list[str] = field(default_factory=list)\n    license: License = field(default_factory=License)\n    urls: URLs = field(default_factory=URLs)\n    supported_os: list[str] = field(default_factory=list)\n    supported_accelerators: list[str] = field(default_factory=list)\n    supported_comfyui_version: str = \"\"\n    supported_comfyui_frontend_version: str = \"\"\n\n\n@dataclass\nclass PyProjectConfig:\n    project: ProjectConfig = field(default_factory=ProjectConfig)\n    tool_comfy: ComfyConfig = field(default_factory=ComfyConfig)\n"
  },
  {
    "path": "comfy_cli/resolve_python.py",
    "content": "from __future__ import annotations\n\nimport os\nimport platform\nimport subprocess\nimport sys\nimport sysconfig\n\nfrom rich import print as rprint\n\n\ndef _get_python_binary(env_path: str) -> str:\n    if platform.system() == \"Windows\":\n        return os.path.join(env_path, \"Scripts\", \"python.exe\")\n    return os.path.join(env_path, \"bin\", \"python\")\n\n\ndef _is_externally_managed() -> bool:\n    \"\"\"Detect PEP 668 externally-managed Python (e.g. Ubuntu 24.04 system Python).\"\"\"\n    stdlib = sysconfig.get_path(\"stdlib\")\n    return bool(stdlib) and os.path.isfile(os.path.join(stdlib, \"EXTERNALLY-MANAGED\"))\n\n\ndef resolve_workspace_python(workspace_path: str | None = None) -> str:\n    if virtual_env := os.environ.get(\"VIRTUAL_ENV\"):\n        python = _get_python_binary(virtual_env)\n        if os.path.isfile(python):\n            return python\n\n    if conda_prefix := os.environ.get(\"CONDA_PREFIX\"):\n        python = _get_python_binary(conda_prefix)\n        if os.path.isfile(python):\n            return python\n\n    if workspace_path is not None:\n        for venv_name in (\".venv\", \"venv\"):\n            venv_dir = os.path.join(workspace_path, venv_name)\n            if os.path.isdir(venv_dir):\n                python = _get_python_binary(venv_dir)\n                if os.path.isfile(python):\n                    return python\n\n    return sys.executable\n\n\ndef create_workspace_venv(workspace_path: str) -> str:\n    venv_dir = os.path.join(workspace_path, \".venv\")\n    rprint(f\"Creating workspace virtual environment at [bold]{venv_dir}[/bold]\")\n    subprocess.run([sys.executable, \"-m\", \"venv\", venv_dir], check=True)\n    python = _get_python_binary(venv_dir)\n    if not os.path.isfile(python):\n        raise RuntimeError(f\"Failed to create venv: {python} not found after creation\")\n    return python\n\n\ndef ensure_workspace_python(workspace_path: str) -> str:\n    if os.environ.get(\"VIRTUAL_ENV\") or os.environ.get(\"CONDA_PREFIX\"):\n        return resolve_workspace_python(workspace_path)\n\n    for venv_name in (\".venv\", \"venv\"):\n        venv_dir = os.path.join(workspace_path, venv_name)\n        if os.path.isdir(venv_dir):\n            python = _get_python_binary(venv_dir)\n            if os.path.isfile(python):\n                return python\n\n    # Running from the system/global Python (e.g. Docker root installs, global pip installs).\n    if sys.prefix == sys.base_prefix:\n        if _is_externally_managed():\n            # PEP 668: system Python is locked (e.g. Ubuntu 24.04), need a workspace venv.\n            return create_workspace_venv(workspace_path)\n        return sys.executable\n\n    # Running from an isolated tool environment (pipx, uv tool, etc.)\n    # Must create a workspace venv to avoid polluting the tool's env\n    return create_workspace_venv(workspace_path)\n"
  },
  {
    "path": "comfy_cli/standalone.py",
    "content": "import logging\nimport re\nimport shutil\nimport subprocess\nfrom pathlib import Path\n\nimport requests\n\nfrom comfy_cli.constants import DEFAULT_STANDALONE_PYTHON_MINOR_VERSION, OS, PROC\nfrom comfy_cli.typing import PathLike\nfrom comfy_cli.utils import create_tarball, download_url, extract_tarball, get_os, get_proc\nfrom comfy_cli.uv import DependencyCompiler\n\nlogger = logging.getLogger(__name__)\n\n_here = Path(__file__).expanduser().resolve().parent\n\n_platform_targets = {\n    (OS.MACOS, PROC.ARM): \"aarch64-apple-darwin\",\n    (OS.MACOS, PROC.X86_64): \"x86_64-apple-darwin\",\n    (OS.LINUX, PROC.X86_64): \"x86_64_v3-unknown-linux-gnu\",  # x86_64_v3 assumes AVX256 support, no AVX512 support\n    (OS.WINDOWS, PROC.X86_64): \"x86_64-pc-windows-msvc\",\n}\n\n_latest_release_json_url = (\n    \"https://raw.githubusercontent.com/astral-sh/python-build-standalone/latest-release/latest-release.json\"\n)\n_asset_url_prefix = \"https://github.com/astral-sh/python-build-standalone/releases/download/{tag}\"\n\n\ndef _resolve_python_version(asset_url_prefix: str, minor_version: str) -> str:\n    \"\"\"Resolve the exact patch version for a minor version series from the release SHA256SUMS.\n\n    Downloads the SHA256SUMS file (~45 KB) from the release and parses it to find\n    the available patch version for the requested minor series (e.g. \"3.12\" -> \"3.12.13\").\n    \"\"\"\n    sha256sums_url = f\"{asset_url_prefix.rstrip('/')}/SHA256SUMS\"\n    response = requests.get(sha256sums_url)\n    response.raise_for_status()\n\n    pattern = re.compile(rf\"cpython-({re.escape(minor_version)}\\.\\d+)\\+\")\n    versions = set()\n    for line in response.text.splitlines():\n        match = pattern.search(line)\n        if match:\n            versions.add(match.group(1))\n\n    if not versions:\n        raise RuntimeError(\n            f\"No Python {minor_version}.x found in release. Available versions can be checked at {sha256sums_url}\"\n        )\n\n    # There should be exactly one patch version per minor series in a release, but pick the highest just in case.\n    resolved = max(versions, key=lambda v: tuple(int(x) for x in v.split(\".\")))\n    logger.info(\"Resolved Python %s -> %s\", minor_version, resolved)\n    return resolved\n\n\ndef download_standalone_python(\n    platform: str | None = None,\n    proc: str | None = None,\n    version: str = DEFAULT_STANDALONE_PYTHON_MINOR_VERSION,\n    tag: str = \"latest\",\n    flavor: str = \"install_only\",\n    cwd: PathLike = \".\",\n    show_progress: bool = True,\n) -> PathLike:\n    \"\"\"grab a pre-built distro from the python-build-standalone project. See\n    https://gregoryszorc.com/docs/python-build-standalone/main/\"\"\"\n    platform = get_os() if platform is None else platform\n    proc = get_proc() if proc is None else proc\n    target = _platform_targets[(platform, proc)]\n\n    if tag == \"latest\":\n        # try to fetch json with info about latest release\n        response = requests.get(_latest_release_json_url)\n        if response.status_code != 200:\n            response.raise_for_status()\n            raise RuntimeError(f\"Request to {_latest_release_json_url} returned status code {response.status_code}\")\n\n        latest_release = response.json()\n        tag = latest_release[\"tag\"]\n        asset_url_prefix = latest_release[\"asset_url_prefix\"]\n    else:\n        asset_url_prefix = _asset_url_prefix.format(tag=tag)\n\n    # If version is a minor version (e.g. \"3.12\"), resolve the exact patch version\n    # from the release metadata. Full versions (e.g. \"3.12.13\") are used as-is.\n    if version.count(\".\") == 1:\n        version = _resolve_python_version(asset_url_prefix, version)\n\n    name = f\"cpython-{version}+{tag}-{target}-{flavor}\"\n    fname = f\"{name}.tar.gz\"\n    url = f\"{asset_url_prefix.rstrip('/')}/{fname.lstrip('/')}\"\n\n    return download_url(url, fname, cwd=cwd, show_progress=show_progress)\n\n\nclass StandalonePython:\n    @staticmethod\n    def FromDistro(\n        platform: str | None = None,\n        proc: str | None = None,\n        version: str = DEFAULT_STANDALONE_PYTHON_MINOR_VERSION,\n        tag: str = \"latest\",\n        flavor: str = \"install_only\",\n        cwd: PathLike = \".\",\n        name: PathLike = \"python\",\n        show_progress: bool = True,\n    ) -> \"StandalonePython\":\n        fpath = download_standalone_python(\n            platform=platform,\n            proc=proc,\n            version=version,\n            tag=tag,\n            flavor=flavor,\n            cwd=cwd,\n            show_progress=show_progress,\n        )\n        return StandalonePython.FromTarball(fpath, name)\n\n    @staticmethod\n    def FromTarball(fpath: PathLike, name: PathLike = \"python\", show_progress: bool = True) -> \"StandalonePython\":\n        fpath = Path(fpath)\n        rpath = fpath.parent / name\n\n        extract_tarball(inPath=fpath, outPath=rpath, show_progress=show_progress)\n\n        return StandalonePython(rpath=rpath)\n\n    def __init__(self, rpath: PathLike):\n        self.rpath = Path(rpath)\n        self.name = self.rpath.name\n        if get_os() == OS.WINDOWS:\n            self.bin = self.rpath\n            self.executable = self.bin / \"python.exe\"\n        else:\n            self.bin = self.rpath / \"bin\"\n            self.executable = self.bin / \"python\"\n\n        # paths to store package artifacts\n        self.cache = self.rpath / \"cache\"\n        self.wheels = self.rpath / \"wheels\"\n\n        self.dep_comp = None\n\n        # upgrade pip if needed, install uv\n        self.pip_install(\"-U\", \"pip\", \"uv\")\n\n    def clean(self):\n        for pycache in self.rpath.glob(\"**/__pycache__\"):\n            shutil.rmtree(pycache)\n\n    def run_module(self, mod: str, *args: str):\n        cmd: list[str] = [\n            str(self.executable),\n            \"-m\",\n            mod,\n            *args,\n        ]\n\n        subprocess.run(cmd, check=True)\n\n    def pip_install(self, *args: str):\n        self.run_module(\"pip\", \"install\", *args)\n\n    def uv_install(self, *args: str):\n        self.run_module(\"uv\", \"pip\", \"install\", *args)\n\n    def install_comfy_cli(self, dev: bool = False):\n        if dev:\n            self.uv_install(str(_here.parent))\n        else:\n            self.uv_install(\"comfy_cli\")\n\n    def run_comfy_cli(self, *args: str):\n        self.run_module(\"comfy_cli\", *args)\n\n    def install_comfy(self, *args: str, gpu_arg: str = \"--nvidia\"):\n        self.run_comfy_cli(\"--here\", \"--skip-prompt\", \"install\", \"--fast-deps\", gpu_arg, *args)\n\n    def dehydrate_comfy_deps(\n        self,\n        comfyDir: PathLike,\n        extraSpecs: list[str] | None = None,\n        packWheels: bool = False,\n    ):\n        self.dep_comp = DependencyCompiler(\n            cwd=comfyDir,\n            executable=self.executable,\n            outDir=self.rpath,\n            extraSpecs=extraSpecs,\n        )\n        self.dep_comp.compile_deps()\n\n        if packWheels:\n            skip_uv = get_os() == OS.WINDOWS\n            self.dep_comp.fetch_dep_wheels(skip_uv=skip_uv)\n\n    def rehydrate_comfy_deps(self, packWheels: bool = False):\n        self.dep_comp = DependencyCompiler(\n            executable=self.executable, outDir=self.rpath, reqFilesCore=[], reqFilesExt=[]\n        )\n\n        if packWheels:\n            self.dep_comp.install_wheels_directly()\n        else:\n            self.dep_comp.install_deps()\n\n    def to_tarball(self, outPath: PathLike | None = None, show_progress: bool = True):\n        # remove any __pycache__ before creating archive\n        self.clean()\n\n        create_tarball(inPath=self.rpath, outPath=outPath, show_progress=show_progress)\n"
  },
  {
    "path": "comfy_cli/tracking.py",
    "content": "import functools\nimport logging as logginglib\nimport uuid\n\nimport typer\nfrom mixpanel import Mixpanel\n\nfrom comfy_cli import constants, logging, ui\nfrom comfy_cli.config_manager import ConfigManager\nfrom comfy_cli.workspace_manager import WorkspaceManager\n\n# Ignore logs from urllib3 that Mixpanel uses.\nlogginglib.getLogger(\"urllib3\").setLevel(logginglib.ERROR)\n\nMIXPANEL_TOKEN = \"93aeab8962b622d431ac19800ccc9f67\"\nmp = Mixpanel(MIXPANEL_TOKEN) if MIXPANEL_TOKEN else None\n\n# Kwargs whose values must never reach tracking system.\n# The key is kept (with a redacted marker) so we can still see whether the option was supplied.\nSENSITIVE_TRACKING_KEYS = frozenset({\"api_key\"})\n\n# Generate a unique tracing ID per command.\nconfig_manager = ConfigManager()\ncli_version = config_manager.get_cli_version()\n\n# tracking all events for a single user\nuser_id = config_manager.get(constants.CONFIG_KEY_USER_ID)\n# tracking all events for a single command\ntracing_id = str(uuid.uuid4())\nworkspace_manager = WorkspaceManager()\n\napp = typer.Typer()\n\n\n@app.command()\ndef enable():\n    init_tracking(True)\n    typer.echo(f\"Tracking is now {'enabled'}.\")\n    init_tracking(True)\n\n\n@app.command()\ndef disable():\n    init_tracking(False)\n    typer.echo(f\"Tracking is now {'disabled'}.\")\n\n\ndef track_event(event_name: str, properties: any = None):\n    if properties is None:\n        properties = {}\n    logging.debug(f\"tracking event called with event_name: {event_name} and properties: {properties}\")\n    enable_tracking = config_manager.get_bool(constants.CONFIG_KEY_ENABLE_TRACKING)\n    if not enable_tracking:\n        return\n\n    try:\n        properties[\"cli_version\"] = cli_version\n        properties[\"tracing_id\"] = tracing_id\n        mp.track(distinct_id=user_id, event_name=event_name, properties=properties)\n    except Exception as e:\n        logging.warning(f\"Failed to track event: {e}\")  # Log the error but do not raise\n\n\ndef track_command(sub_command: str = None):\n    \"\"\"\n    A decorator factory that logs the command function name and selected arguments when it's called.\n    \"\"\"\n\n    def decorator(func):\n        @functools.wraps(func)\n        def wrapper(*args, **kwargs):\n            command_name = f\"{sub_command}:{func.__name__}\" if sub_command is not None else func.__name__\n\n            # Copy kwargs to avoid mutating original dictionary\n            # Remove context and ctx from the dictionary as they are not needed for tracking and not serializable.\n            filtered_kwargs = {\n                k: (\"<redacted>\" if v is not None else None) if k in SENSITIVE_TRACKING_KEYS else v\n                for k, v in kwargs.items()\n                if k != \"ctx\" and k != \"context\"\n            }\n\n            logging.debug(f\"Tracking command: {command_name} with arguments: {filtered_kwargs}\")\n            track_event(command_name, properties=filtered_kwargs)\n\n            return func(*args, **kwargs)\n\n        return wrapper\n\n    return decorator\n\n\ndef prompt_tracking_consent(skip_prompt: bool = False, default_value: bool = False):\n    tracking_enabled = config_manager.get_bool(constants.CONFIG_KEY_ENABLE_TRACKING)\n    if tracking_enabled is not None:\n        return\n\n    if skip_prompt:\n        init_tracking(default_value)\n    else:\n        enable_tracking = ui.prompt_confirm_action(\"Do you agree to enable tracking to improve the application?\", False)\n        init_tracking(enable_tracking)\n\n\ndef init_tracking(enable_tracking: bool):\n    \"\"\"\n    Initialize the tracking system by setting the user identifier and tracking enabled status.\n    \"\"\"\n    global user_id\n    logging.debug(f\"Initializing tracking with enable_tracking: {enable_tracking}\")\n    config_manager.set(constants.CONFIG_KEY_ENABLE_TRACKING, str(enable_tracking))\n    if not enable_tracking:\n        return\n\n    curr_user_id = config_manager.get(constants.CONFIG_KEY_USER_ID)\n    logging.debug(f'User identifier for tracking user_id found: {curr_user_id}.\"')\n    if curr_user_id is None:\n        curr_user_id = str(uuid.uuid4())\n        config_manager.set(constants.CONFIG_KEY_USER_ID, curr_user_id)\n        logging.debug(f'Setting user identifier for tracking user_id: {curr_user_id}.\"')\n    user_id = curr_user_id\n\n    # Note: only called once when the user interacts with the CLI for the\n    #  first time iff the permission is granted.\n    install_event_triggered = config_manager.get_bool(constants.CONFIG_KEY_INSTALL_EVENT_TRIGGERED)\n    if not install_event_triggered:\n        logging.debug(\"Tracking install event.\")\n        config_manager.set(constants.CONFIG_KEY_INSTALL_EVENT_TRIGGERED, \"True\")\n        track_event(\"install\")\n"
  },
  {
    "path": "comfy_cli/typing.py",
    "content": "import os\n\nPathLike = os.PathLike[str] | str\n"
  },
  {
    "path": "comfy_cli/ui.py",
    "content": "from enum import Enum\nfrom typing import Any, TypeVar\n\nimport questionary\nimport typer\nfrom questionary import Choice\nfrom rich.console import Console\nfrom rich.progress import Progress\nfrom rich.table import Table\n\nfrom comfy_cli.workspace_manager import WorkspaceManager\n\nconsole = Console()\nworkspace_manager = WorkspaceManager()\n\n\ndef show_progress(iterable, total, description=\"Downloading...\"):\n    \"\"\"\n    Display progress bar for iterable processes, especially useful for file downloads.\n    Each item in the iterable should be a chunk of data, and the progress bar will advance\n    by the size of each chunk.\n\n    Args:\n        iterable (Iterable[bytes]): An iterable that yields chunks of data.\n        total (int): The total size of the data (e.g., total number of bytes) to be downloaded.\n        description (str): Description text for the progress bar.\n\n    Yields:\n        bytes: Chunks of data as they are processed.\n    \"\"\"\n    with Progress(transient=True) as progress:\n        task = progress.add_task(description, total=total)\n        for chunk in iterable:\n            yield chunk\n            progress.update(task, advance=len(chunk))\n\n\nChoiceType = str | Choice | dict[str, Any]\n\n\ndef prompt_autocomplete(\n    question: str, choices: list[ChoiceType], default: ChoiceType = \"\", force_prompting: bool = False\n) -> ChoiceType | None:\n    \"\"\"\n    Asks a single select question using questionary and returns the selected response.\n\n    Args:\n        question (str): The question to display to the user.\n        choices (List[ChoiceType]): A list of choices the user can autocomplete from.\n        default (ChoiceType): Default choice.\n        force_prompting (bool): Whether to force prompting even if skip_prompting is set.\n\n    Returns:\n        Optional[ChoiceType]: The selected choice from the user, or None if skipping prompts.\n    \"\"\"\n    if workspace_manager.skip_prompting and not force_prompting:\n        return None\n    return questionary.autocomplete(question, choices=choices, default=default).ask()\n\n\ndef prompt_select(\n    question: str, choices: list[ChoiceType], default: ChoiceType = \"\", force_prompting: bool = False\n) -> ChoiceType | None:\n    \"\"\"\n    Asks a single select question using questionary and returns the selected response.\n\n    Args:\n        question (str): The question to display to the user.\n        choices (List[ChoiceType]): A list of choices for the user to select from.\n        default (ChoiceType): Default choice.\n        force_prompting (bool): Whether to force prompting even if skip_prompting is set.\n\n    Returns:\n        Optional[ChoiceType]: The selected choice from the user, or None if skipping prompts.\n    \"\"\"\n    if workspace_manager.skip_prompting and not force_prompting:\n        return None\n    return questionary.select(question, choices=choices, default=default).ask()\n\n\nE = TypeVar(\"E\", bound=Enum)\n\n\ndef prompt_select_enum(question: str, choices: list[E], force_prompting: bool = False) -> E | None:\n    \"\"\"\n    Asks a single select question using questionary and returns the selected response.\n\n    Args:\n        question (str): The question to display to the user.\n        choices (List[E]): A list of Enum choices for the user to select from.\n        force_prompting (bool): Whether to force prompting even if skip_prompting is set.\n\n    Returns:\n        Optional[E]: The selected Enum choice from the user, or None if skipping prompts.\n    \"\"\"\n    if workspace_manager.skip_prompting and not force_prompting:\n        return None\n\n    choice_map = {choice.value: choice for choice in choices}\n    display_choices = list(choice_map.keys())\n\n    selected = questionary.select(question, choices=display_choices).ask()\n\n    return choice_map[selected] if selected is not None else None\n\n\ndef prompt_input(question: str, default: str = \"\", force_prompting: bool = False) -> str:\n    \"\"\"\n    Asks the user for an input using questionary.\n\n    Args:\n        question (str): The question to display to the user.\n        default (str): The default value for the input.\n\n    Returns:\n        str: The user's input.\n\n    Raises:\n        KeyboardInterrupt: If the user interrupts the input.\n    \"\"\"\n    if workspace_manager.skip_prompting and not force_prompting:\n        return default\n    return questionary.text(question, default=default).ask()\n\n\ndef prompt_multi_select(prompt: str, choices: list[str]) -> list[str]:\n    \"\"\"\n    Prompts the user to select multiple items from a list of choices.\n\n    Args:\n        prompt (str): The message to display to the user.\n        choices (List[str]): A list of choices from which the user can select.\n\n    Returns:\n        List[str]: A list of the selected items.\n    \"\"\"\n    selections = questionary.checkbox(prompt, choices=choices).ask()  # returns list of selected items\n    return selections if selections else []\n\n\ndef prompt_confirm_action(prompt: str, default: bool) -> bool:\n    \"\"\"\n    Prompts the user for confirmation before proceeding with an action.\n\n    Args:\n        prompt (str): The confirmation message to display to the user.\n\n    Returns:\n        bool: True if the user confirms, False otherwise.\n    \"\"\"\n    if workspace_manager.skip_prompting:\n        return default\n\n    return typer.confirm(prompt)\n\n\ndef display_table(data: list[tuple], column_names: list[str], title: str = \"\") -> None:\n    \"\"\"\n    Displays a list of tuples in a table format using Rich.\n\n    Args:\n        data (List[Tuple]): A list of tuples, where each tuple represents a row.\n        column_names (List[str]): A list of column names for the table.\n        title (str): The title of the table.\n    \"\"\"\n    table = Table(title=title)\n\n    for name in column_names:\n        table.add_column(name, overflow=\"fold\")\n\n    for row in data:\n        table.add_row(*[str(item) for item in row])\n\n    console.print(table)\n\n\ndef display_error_message(message: str) -> None:\n    \"\"\"\n    Displays an error message to the user in red text.\n\n    Args:\n        message (str): The error message to display.\n    \"\"\"\n    # markup=False so a dynamic message containing e.g. \"[/]\" doesn't raise\n    # MarkupError or silently strip bracketed substrings.\n    console.print(message, style=\"red\", markup=False)\n"
  },
  {
    "path": "comfy_cli/update.py",
    "content": "import logging\nimport sys\nfrom importlib.metadata import metadata\n\nimport requests\nfrom packaging import version\nfrom rich.console import Console\nfrom rich.panel import Panel\n\nlogger = logging.getLogger(__name__)\nconsole = Console()\n\n\ndef check_for_newer_pypi_version(package_name, current_version):\n    url = f\"https://pypi.org/pypi/{package_name}/json\"\n    try:\n        response = requests.get(url, timeout=5)\n        response.raise_for_status()\n        latest_version = response.json()[\"info\"][\"version\"]\n\n        if version.parse(latest_version) > version.parse(current_version):\n            return True, latest_version\n\n        return False, current_version\n    except requests.RequestException as e:\n        logger.warning(f\"Failed to check for updates: {e}\")\n        return False, current_version\n\n\ndef check_for_updates():\n    current_version = get_version_from_pyproject()\n    has_newer, newer_version = check_for_newer_pypi_version(\"comfy-cli\", current_version)\n\n    if has_newer:\n        notify_update(current_version, newer_version)\n\n\ndef get_version_from_pyproject():\n    package_metadata = metadata(\"comfy-cli\")\n    return package_metadata[\"Version\"]\n\n\ndef notify_update(current_version: str, newer_version: str):\n    message = (\n        f\":sparkles: Newer version of [bold magenta]comfy-cli[/bold magenta] is available: [bold green]{newer_version}[/bold green].\\n\"\n        f\"Current version: [bold cyan]{current_version}[/bold cyan]\\n\"\n        f\"Update by running: [bold yellow]'pip install --upgrade comfy-cli'[/bold yellow] :arrow_up:\"\n    )\n\n    if sys.platform == \"win32\":\n        # windows cannot display emoji characters.\n        bell = \"\"\n        message = message.replace(\":sparkles:\", \"\")\n        message = message.replace(\":arrow_up:\", \"\")\n    else:\n        bell = \":bell:\"\n\n    console.print(\n        Panel(\n            message,\n            title=f\"[bold red]{bell} Update Available![/bold red]\",\n            border_style=\"bright_blue\",\n        )\n    )\n"
  },
  {
    "path": "comfy_cli/utils.py",
    "content": "\"\"\"\nModule for utility functions.\n\"\"\"\n\nimport functools\nimport platform\nimport shutil\nimport subprocess\nimport tarfile\nfrom pathlib import Path\n\nimport psutil\nimport requests\nimport typer\nfrom rich import print, progress\nfrom rich.live import Live\nfrom rich.table import Table\n\nfrom comfy_cli.constants import DEFAULT_COMFY_WORKSPACE, OS, PROC\nfrom comfy_cli.typing import PathLike\n\n\ndef singleton(cls):\n    \"\"\"\n    Decorator that implements the Singleton pattern for the decorated class.\n\n    e.g.\n    @singleton\n    class MyClass:\n        pass\n\n    \"\"\"\n    instances = {}\n\n    def get_instance(*args, **kwargs):\n        if cls not in instances:\n            instances[cls] = cls(*args, **kwargs)\n        return instances[cls]\n\n    return get_instance\n\n\ndef get_os():\n    platform_system = platform.system().lower()\n\n    if platform_system == \"darwin\":\n        return OS.MACOS\n    elif platform_system == \"windows\":\n        return OS.WINDOWS\n    elif platform_system == \"linux\":\n        return OS.LINUX\n    else:\n        raise ValueError(f\"Running on unsupported os {platform.system()}\")\n\n\ndef get_proc():\n    proc = platform.machine()\n\n    if proc == \"x86_64\" or proc == \"AMD64\":\n        return PROC.X86_64\n    elif \"arm\" in proc:\n        return PROC.ARM\n    else:\n        raise ValueError\n\n\ndef install_conda_package(package_name):\n    try:\n        subprocess.check_call([\"conda\", \"install\", \"-y\", package_name])\n        print(f\"[bold green] Successfully installed {package_name} [/bold green]\")\n    except subprocess.CalledProcessError as e:\n        print(f\"[bold red] Failed to install {package_name}. Error: {e} [/bold red]\")\n        raise typer.Exit(code=1)\n\n\ndef get_not_user_set_default_workspace():\n    return DEFAULT_COMFY_WORKSPACE[get_os()]\n\n\ndef kill_all(pid):\n    try:\n        parent = psutil.Process(pid)\n        children = parent.children(recursive=True)\n        for child in children:\n            child.kill()\n        return True\n    except Exception:\n        return False\n\n\ndef is_running(pid):\n    try:\n        psutil.Process(pid)\n        return True\n    except psutil.NoSuchProcess:\n        return False\n\n\ndef create_choice_completer(opts: list[str]):\n    def f(incomplete: str) -> list[str]:\n        return [opt for opt in opts if opt.startswith(incomplete)]\n\n    return f\n\n\ndef download_url(\n    url: str,\n    fname: PathLike,\n    cwd: PathLike = \".\",\n    allow_redirects: bool = True,\n    show_progress: bool = True,\n) -> PathLike:\n    \"\"\"download url to local file fname and show a progress bar.\n    See https://stackoverflow.com/q/37573483\"\"\"\n    cwd = Path(cwd).expanduser().resolve()\n    fpath = cwd / fname\n\n    response = requests.get(url, stream=True, allow_redirects=allow_redirects)\n    if response.status_code != 200:\n        response.raise_for_status()  # Will only raise for 4xx codes, so...\n        raise RuntimeError(f\"Request to {url} returned status code {response.status_code}\")\n\n    response.raw.read = functools.partial(response.raw.read, decode_content=True)  # Decompress if needed\n    with fpath.open(\"wb\") as f:\n        if show_progress:\n            fsize = int(response.headers.get(\"Content-Length\", 0))\n            desc = f\"downloading {fname}...\" + (\"(Unknown total file size)\" if fsize == 0 else \"\")\n\n            with progress.wrap_file(response.raw, total=fsize, description=desc) as response_raw:\n                shutil.copyfileobj(response_raw, f)\n        else:\n            shutil.copyfileobj(response.raw, f)\n\n    return fpath\n\n\ndef extract_tarball(\n    inPath: PathLike,\n    outPath: PathLike | None = None,\n    show_progress: bool = True,\n):\n    inPath = Path(inPath).expanduser().resolve()\n    outPath = inPath.with_suffix(\"\") if outPath is None else Path(outPath).expanduser().resolve()\n\n    with tarfile.open(inPath) as tar:\n        info = tar.next()\n        old_name = info.name.split(\"/\")[0]\n    # path to top-level of extraction result\n    extractPath = inPath.with_name(old_name)\n\n    # clean both the extraction path and the final target path\n    shutil.rmtree(extractPath, ignore_errors=True)\n    shutil.rmtree(outPath, ignore_errors=True)\n\n    if show_progress:\n        fileSize = inPath.stat().st_size\n\n        barProg = progress.Progress()\n        barTask = barProg.add_task(\"[cyan]extracting tarball...\", total=fileSize)\n        pathProg = progress.Progress(progress.TextColumn(\"{task.description}\"))\n        pathTask = pathProg.add_task(\"\")\n\n        progress_table = Table.grid()\n        progress_table.add_row(barProg)\n        progress_table.add_row(pathProg)\n\n        _size = 0\n\n        def _filter(tinfo: tarfile.TarInfo, _path: PathLike):\n            nonlocal _size\n            pathProg.update(pathTask, description=tinfo.path)\n            barProg.advance(barTask, _size)\n            _size = tinfo.size\n\n            # TODO: ideally we'd use data_filter here, but it's busted: https://github.com/python/cpython/issues/107845\n            # return tarfile.data_filter(tinfo, _path)\n            return tinfo\n    else:\n        _filter = None\n\n    with Live(progress_table, refresh_per_second=10):\n        with tarfile.open(inPath) as tar:\n            tar.extractall(filter=_filter)\n\n        if show_progress:\n            barProg.advance(barTask, _size)\n            pathProg.update(pathTask, description=\"\")\n\n    shutil.move(extractPath, outPath)\n\n\ndef create_tarball(\n    inPath: PathLike,\n    outPath: PathLike | None = None,\n    cwd: PathLike | None = None,\n    show_progress: bool = True,\n):\n    cwd = Path(\".\" if cwd is None else cwd).expanduser().resolve()\n    inPath = Path(inPath).expanduser().resolve()\n    outPath = inPath.with_suffix(\".tgz\") if outPath is None else Path(outPath).expanduser().resolve()\n\n    # clean the archive target path\n    outPath.unlink(missing_ok=True)\n\n    if show_progress:\n        fileSize = sum(f.stat().st_size for f in inPath.glob(\"**/*\"))\n\n        barProg = progress.Progress()\n        barTask = barProg.add_task(\"[cyan]creating tarball...\", total=fileSize)\n        pathProg = progress.Progress(progress.TextColumn(\"{task.description}\"))\n        pathTask = pathProg.add_task(\"\")\n\n        progress_table = Table.grid()\n        progress_table.add_row(barProg)\n        progress_table.add_row(pathProg)\n\n        _size = 0\n\n        def _filter(tinfo: tarfile.TarInfo):\n            nonlocal _size\n            pathProg.update(pathTask, description=tinfo.path)\n            barProg.advance(barTask, _size)\n            _size = Path(tinfo.path).stat().st_size\n\n            return tinfo\n    else:\n        _filter = None\n\n    with Live(progress_table, refresh_per_second=10):\n        with tarfile.open(outPath, \"w:gz\") as tar:\n            # don't include parent paths in archive\n            tar.add(inPath.relative_to(cwd), filter=_filter)\n\n        if show_progress:\n            barProg.advance(barTask, _size)\n            pathProg.update(pathTask, description=\"\")\n"
  },
  {
    "path": "comfy_cli/uv.py",
    "content": "import re\nimport subprocess\nimport sys\nfrom importlib import metadata\nfrom pathlib import Path\nfrom textwrap import dedent\nfrom typing import Any, cast\n\nfrom comfy_cli import ui\nfrom comfy_cli.constants import GPU_OPTION\nfrom comfy_cli.typing import PathLike\n\n\ndef _run(cmd: list[str], cwd: PathLike, check: bool = True) -> subprocess.CompletedProcess[Any]:\n    return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, check=check)\n\n\ndef _check_call(cmd: list[str], cwd: PathLike | None = None):\n    \"\"\"uses check_call to run pip, as reccomended by the pip maintainers.\n    see https://pip.pypa.io/en/stable/user_guide/#using-pip-from-your-program\"\"\"\n    try:\n        subprocess.check_call(cmd, cwd=cwd)\n    except subprocess.CalledProcessError:\n        if len(cmd) >= 5 and cmd[1:4] == [\"-m\", \"uv\", \"pip\"] and cmd[4] in (\"install\", \"sync\"):\n            from rich import print as rprint\n\n            rprint(\n                \"\\n[bold yellow]Hint:[/bold yellow] If you are on a network filesystem \"\n                \"(RunPod, NFS, etc.), this may be caused by a known uv issue.\\n\"\n                \"Try setting one of these environment variables before running comfy:\\n\"\n                \"  [green]export UV_LINK_MODE=copy[/green]\\n\"\n                \"  [green]export UV_CACHE_DIR=<your-workspace>/.cache/uv[/green]\\n\"\n                \"See https://github.com/astral-sh/uv/issues/12036 for details.\"\n            )\n        raise\n\n\n_req_name_re: re.Pattern[str] = re.compile(r\"require\\s([\\w-]+)\")\n\n# Mirrors pip's requirements-file comment rule (pip._internal.req.req_file.COMMENT_RE):\n# `#` only starts a comment when preceded by whitespace (or starts the line), so\n# VCS URL fragments like `#subdirectory=pkg` and `#egg=foo` survive.\n_inline_comment_re: re.Pattern[str] = re.compile(r\"(^|\\s+)#.*$\")\n\n\ndef _req_re_closure(name: str) -> re.Pattern[str]:\n    return re.compile(rf\"({name}\\S+)\")\n\n\ndef parse_uv_compile_error(err: str) -> tuple[str, list[str]]:\n    \"\"\"takes in stderr from a run of `uv pip compile` that failed due to requirement conflict and spits out\n    a tuple of (reqiurement_name, [requirement_spec_in_conflict_a, requirement_spec_in_conflict_b]). Will probably\n    fail for stderr produced from other kinds of errors\n    \"\"\"\n    if reqNameMatch := _req_name_re.search(err):\n        reqName = reqNameMatch[1]\n    else:\n        raise ValueError\n\n    reqRe = _req_re_closure(reqName)\n\n    return reqName, cast(list[str], reqRe.findall(err))\n\n\ndef parse_req_file(rf: PathLike, skips: list[str] | None = None):\n    skips = [] if skips is None else skips\n\n    reqs: list[str] = []\n    opts: list[str] = []\n    with open(rf) as f:\n        for line in f:\n            line = _inline_comment_re.sub(\"\", line).strip()\n            if not line:\n                continue\n            elif \"==\" in line and line.split(\"==\")[0] in skips:\n                continue\n            elif line.startswith(\"--\"):\n                opts.extend(line.split())\n            else:\n                reqs.append(line)\n\n    return opts + reqs\n\n\nclass DependencyCompiler:\n    cpuPytorchUrl = \"https://download.pytorch.org/whl/cpu\"\n    rocmPytorchUrl = \"https://download.pytorch.org/whl/rocm6.3\"\n    nvidiaPytorchUrl = \"https://download.pytorch.org/whl/cu126\"\n\n    cpuTorchBackend = \"cpu\"\n    rocmTorchBackend = \"rocm6.3\"\n    nvidiaTorchBackend = \"cu126\"\n\n    overrideGpu = dedent(\n        \"\"\"\n        # ensure usage of {gpu} version of pytorch\n        torch\n        torchaudio\n        torchsde\n        torchvision\n    \"\"\"\n    ).strip()\n\n    reqNames = (\n        \"requirements.txt\",\n        \"pyproject.toml\",\n        \"setup.cfg\",\n        \"setup.py\",\n    )\n\n    @staticmethod\n    def Find_Req_Files(*ders: PathLike) -> list[Path]:\n        reqFiles = []\n        for der in ders:\n            reqFound = False\n            for reqName in DependencyCompiler.reqNames:\n                for file in Path(der).absolute().iterdir():\n                    if file.name == reqName:\n                        reqFiles.append(file)\n                        reqFound = True\n                        break\n                if reqFound:\n                    break\n\n        return reqFiles\n\n    @staticmethod\n    def Install_Build_Deps(executable: PathLike = sys.executable):\n        \"\"\"Use pip to install bare minimum requirements for uv to do its thing\"\"\"\n        cmd = [str(executable), \"-m\", \"pip\", \"install\", \"--upgrade\", \"pip\", \"uv\"]\n        _check_call(cmd=cmd)\n\n    @staticmethod\n    def Compile(\n        cwd: PathLike,\n        reqFiles: list[PathLike],\n        emit_index_annotation: bool = True,\n        emit_index_url: bool = True,\n        executable: PathLike = sys.executable,\n        index_strategy: str = \"unsafe-best-match\",\n        out: PathLike | None = None,\n        override: PathLike | None = None,\n        resolve_strategy: str | None = None,\n        torch_backend: str | None = None,\n    ) -> subprocess.CompletedProcess[Any]:\n        cmd = [\n            str(executable),\n            \"-m\",\n            \"uv\",\n            \"pip\",\n            \"compile\",\n        ]\n\n        for reqFile in reqFiles:\n            cmd.append(str(reqFile))\n\n        if emit_index_annotation:\n            cmd.append(\"--emit-index-annotation\")\n\n        if emit_index_url:\n            cmd.append(\"--emit-index-url\")\n\n        if torch_backend is not None:\n            cmd.extend([\"--torch-backend\", torch_backend])\n\n        # ensures that eg tqdm is latest version, even though an old tqdm is on the amd url\n        # see https://github.com/astral-sh/uv/blob/main/PIP_COMPATIBILITY.md#packages-that-exist-on-multiple-indexes\n        if index_strategy is not None:\n            cmd.extend([\"--index-strategy\", \"unsafe-best-match\"])\n\n        if out is not None:\n            cmd.extend([\"-o\", str(out)])\n\n        if override is not None:\n            cmd.extend([\"--override\", str(override)])\n\n        try:\n            return _run(cmd, cwd)\n        except subprocess.CalledProcessError as e:\n            print(e.__class__.__name__)\n            print(e)\n            print(f\"STDOUT:\\n{e.stdout}\")\n            print(f\"STDERR:\\n{e.stderr}\")\n\n            if resolve_strategy == \"ask\":\n                name, reqs = parse_uv_compile_error(e.stderr)\n                vers = [req.split(name)[1].strip(\",\") for req in reqs]\n\n                ver = ui.prompt_select(\n                    \"Please pick one of the conflicting version specs (or pick latest):\",\n                    choices=vers + [\"latest\"],\n                    default=vers[0],\n                )\n\n                if ver == \"latest\":\n                    req = name\n                else:\n                    req = name + ver\n\n                e.req = req\n            elif resolve_strategy is not None:\n                # no other resolve_strategy options implemented yet\n                raise ValueError\n\n            raise e\n\n    @staticmethod\n    def Install(\n        cwd: PathLike,\n        executable: PathLike = sys.executable,\n        dry: bool = False,\n        extra_index_url: str | None = None,\n        find_links: list[str] | None = None,\n        index_strategy: str | None = \"unsafe-best-match\",\n        no_deps: bool = False,\n        no_index: bool = False,\n        override: PathLike | None = None,\n        reqs: list[str] | None = None,\n        reqFile: list[PathLike] | None = None,\n    ) -> None:\n        cmd = [\n            str(executable),\n            \"-m\",\n            \"uv\",\n            \"pip\",\n            \"install\",\n        ]\n\n        if dry:\n            cmd.append(\"--dry-run\")\n\n        if extra_index_url is not None:\n            cmd.extend([\"--extra-index-url\", extra_index_url])\n\n        if find_links is not None:\n            for fl in find_links:\n                cmd.extend([\"--find-links\", fl])\n\n        if index_strategy is not None:\n            cmd.extend([\"--index-strategy\", \"unsafe-best-match\"])\n\n        if no_deps:\n            cmd.append(\"--no-deps\")\n\n        if no_index:\n            cmd.append(\"--no-index\")\n\n        if override is not None:\n            cmd.extend([\"--override\", str(override)])\n\n        if reqs is not None:\n            cmd.extend(reqs)\n\n        if reqFile is not None:\n            for rf in reqFile:\n                cmd.extend([\"--requirement\", rf])\n\n        return _check_call(cmd, cwd)\n\n    @staticmethod\n    def Sync(\n        cwd: PathLike,\n        reqFile: list[PathLike],\n        dry: bool = False,\n        executable: PathLike = sys.executable,\n        extraUrl: str | None = None,\n        index_strategy: str = \"unsafe-best-match\",\n    ) -> None:\n        cmd = [\n            str(executable),\n            \"-m\",\n            \"uv\",\n            \"pip\",\n            \"sync\",\n            str(reqFile),\n        ]\n\n        if index_strategy is not None:\n            cmd.extend([\"--index-strategy\", \"unsafe-best-match\"])\n\n        if extraUrl is not None:\n            cmd.extend([\"--extra-index-url\", extraUrl])\n\n        if dry:\n            cmd.append(\"--dry-run\")\n\n        return _check_call(cmd, cwd)\n\n    @staticmethod\n    def Download(\n        cwd: PathLike,\n        executable: PathLike = sys.executable,\n        extraUrl: str | None = None,\n        noDeps: bool = False,\n        out: PathLike | None = None,\n        reqs: list[str] | None = None,\n        reqFile: list[PathLike] | None = None,\n    ) -> None:\n        \"\"\"For now, the `download` cmd has no uv support, so use pip\"\"\"\n        cmd = [\n            str(executable),\n            \"-m\",\n            \"pip\",\n            \"download\",\n        ]\n\n        if extraUrl is not None:\n            cmd.extend([\"--extra-index-url\", extraUrl])\n\n        if noDeps:\n            cmd.append(\"--no-deps\")\n\n        if out is not None:\n            cmd.extend([\"-d\", str(out)])\n\n        if reqs is not None:\n            cmd.extend(reqs)\n\n        if reqFile is not None:\n            for rf in reqFile:\n                cmd.extend([\"--requirement\", rf])\n\n        return _check_call(cmd, cwd)\n\n    @staticmethod\n    def Wheel(\n        cwd: PathLike,\n        executable: PathLike = sys.executable,\n        extraUrl: str | None = None,\n        noDeps: bool = False,\n        out: PathLike | None = None,\n        reqs: list[str] | None = None,\n        reqFile: list[PathLike] | None = None,\n    ) -> None:\n        \"\"\"For now, the `wheel` cmd has no uv support, so use pip\"\"\"\n        cmd = [\n            str(executable),\n            \"-m\",\n            \"pip\",\n            \"wheel\",\n        ]\n\n        if extraUrl is not None:\n            cmd.extend([\"--extra-index-url\", extraUrl])\n\n        if noDeps:\n            cmd.append(\"--no-deps\")\n\n        if out is not None:\n            cmd.extend([\"-w\", str(out)])\n\n        if reqs is not None:\n            cmd.extend(reqs)\n\n        if reqFile is not None:\n            for rf in reqFile:\n                cmd.extend([\"--requirement\", rf])\n\n        return _check_call(cmd, cwd)\n\n    @staticmethod\n    def Resolve_Gpu(gpu: GPU_OPTION | None):\n        if gpu is None:\n            try:\n                tver = metadata.version(\"torch\")\n                if \"+cu\" in tver:\n                    return GPU_OPTION.NVIDIA\n                elif \"+rocm\" in tver:\n                    return GPU_OPTION.AMD\n                else:\n                    return None\n            except metadata.PackageNotFoundError:\n                return None\n        else:\n            return gpu\n\n    def __init__(\n        self,\n        cwd: PathLike = \".\",\n        executable: PathLike = sys.executable,\n        gpu: GPU_OPTION | None = None,\n        outDir: PathLike = \".\",\n        outName: str = \"requirements.compiled\",\n        reqFilesCore: list[PathLike] | None = None,\n        reqFilesExt: list[PathLike] | None = None,\n        extraSpecs: list[str] | None = None,\n        cuda_version: str | None = None,\n        rocm_version: str | None = None,\n        skip_torch: bool = False,\n    ):\n        \"\"\"Compiler/installer of Python dependencies based on uv\n\n        Args:\n            cwd (PathLike): should generally be a comfy workspace dir. Dir that is searched for dependency specification files, and where subprocesses are run in\n            executable (PathLike): path to Python executable used to run uv and other subprocesses\n            gpu (Union[GPU_OPTION, None]): the gpu against which pytorch and any related dependencies should be built against\n            outDir (PathLike): the directory in which to create any output from the compiler itself\n            outName (str): the name of the output file containing the compiled requirements\n            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\n            reqFilesExt (Optional[list[PathLike]]): list of requirement files (requirements.txt, pyproject.toml, etc) to be included in the compilation\n            extraSpecs (Optional[list[str]]): list of extra Python requirement specifiers to be included in the compilation\n            skip_torch (bool): if True, skip torch/torchvision/torchaudio installation and GPU index URLs\n        \"\"\"\n        self.cwd = Path(cwd).expanduser().resolve()\n        self.outDir: Path = Path(outDir).expanduser().resolve()\n        # use .absolute since .resolve breaks the softlink-is-interpreter assumption of venvs\n        self.executable = Path(executable).expanduser().absolute()\n        self.gpu = DependencyCompiler.Resolve_Gpu(gpu)\n        self.skip_torch = skip_torch\n        self.reqFiles = [Path(reqFile) for reqFile in reqFilesExt] if reqFilesExt is not None else None\n        self.extraSpecs = [] if extraSpecs is None else extraSpecs\n\n        if self.skip_torch:\n            self.gpuUrl = None\n            self.torchBackend = None\n        elif self.gpu == GPU_OPTION.NVIDIA:\n            tag = f\"cu{cuda_version.replace('.', '')}\" if cuda_version else DependencyCompiler.nvidiaTorchBackend\n            self.gpuUrl = f\"https://download.pytorch.org/whl/{tag}\"\n            self.torchBackend = tag\n        elif self.gpu == GPU_OPTION.AMD:\n            tag = f\"rocm{rocm_version}\" if rocm_version else DependencyCompiler.rocmTorchBackend\n            self.gpuUrl = f\"https://download.pytorch.org/whl/{tag}\"\n            self.torchBackend = tag\n        elif self.gpu == GPU_OPTION.CPU:\n            self.gpuUrl = DependencyCompiler.cpuPytorchUrl\n            self.torchBackend = DependencyCompiler.cpuTorchBackend\n        else:\n            self.gpuUrl = None\n            self.torchBackend = None\n        self.out: Path = self.outDir / outName\n        self.override = self.outDir / \"override.txt\"\n\n        self.reqFilesCore = reqFilesCore if reqFilesCore is not None else self.find_core_reqs()\n        self.reqFilesExt = reqFilesExt if reqFilesExt is not None else self.find_ext_reqs()\n\n    def find_core_reqs(self):\n        return DependencyCompiler.Find_Req_Files(self.cwd)\n\n    def find_ext_reqs(self):\n        extDirs = [d for d in (self.cwd / \"custom_nodes\").iterdir() if d.is_dir() and d.name != \"__pycache__\"]\n        return DependencyCompiler.Find_Req_Files(*extDirs)\n\n    def make_override(self):\n        # clean up\n        self.override.unlink(missing_ok=True)\n\n        with open(self.override, \"w\") as f:\n            if self.torchBackend is not None:\n                f.write(DependencyCompiler.overrideGpu.format(gpu=self.gpu))\n                f.write(\"\\n\\n\")\n\n        completed = DependencyCompiler.Compile(\n            cwd=self.cwd,\n            reqFiles=self.reqFilesCore,\n            emit_index_annotation=False,\n            emit_index_url=False,\n            executable=self.executable,\n            override=self.override,\n            torch_backend=self.torchBackend,\n        )\n\n        with open(self.override, \"a\") as f:\n            f.write(\"# ensure that core comfyui deps take precedence over any 3rd party extension deps\\n\")\n            for line in completed.stdout.splitlines(keepends=True):\n                # Skip bare cuda-toolkit pins — torch>=2.11 depends on\n                # cuda-toolkit[cublas,cudart,...] and uv --override replaces\n                # the full spec, stripping extras and dropping CUDA runtime\n                # packages (nvidia-cuda-runtime, nvidia-cuda-nvrtc, …). #412\n                if line.strip().startswith(\"cuda-toolkit==\"):\n                    continue\n                f.write(line)\n            f.write(\"\\n\")\n\n    def compile_core_plus_ext(self):\n        reqExtras = self.outDir / \"requirements.extra\"\n        # clean up\n        reqExtras.unlink(missing_ok=True)\n        self.out.unlink(missing_ok=True)\n\n        # make the extra specs file\n        if self.extraSpecs:\n            with reqExtras.open(\"w\") as f:\n                for spec in self.extraSpecs:\n                    f.write(spec)\n                f.write(\"\\n\")\n\n        while True:\n            try:\n                DependencyCompiler.Compile(\n                    cwd=self.cwd,\n                    reqFiles=self.reqFilesCore + self.reqFilesExt + ([reqExtras] if self.extraSpecs else []),\n                    executable=self.executable,\n                    override=self.override,\n                    out=self.out,\n                    resolve_strategy=\"ask\",\n                    torch_backend=self.torchBackend,\n                )\n\n                break\n            except subprocess.CalledProcessError as e:\n                if hasattr(e, \"req\"):\n                    with open(self.override, \"a\") as f:\n                        f.write(e.req + \"\\n\")\n                else:\n                    raise AttributeError\n\n    def handle_opencv(self):\n        \"\"\"as per the opencv docs, you should only have exactly one opencv package.\n        headless is more suitable for comfy than the gui version, so remove gui if\n        headless is present. TODO: add support for contrib pkgs. see: https://github.com/opencv/opencv-python\"\"\"\n\n        with open(self.out) as f:\n            lines = f.readlines()\n\n        guiFound, headlessFound = False, False\n        for line in lines:\n            if \"opencv-python==\" in line:\n                guiFound = True\n            elif \"opencv-python-headless==\" in line:\n                headlessFound = True\n\n        if headlessFound and guiFound:\n            with open(self.out, \"w\") as f:\n                for line in lines:\n                    if \"opencv-python==\" not in line:\n                        f.write(line)\n\n    def compile_deps(self):\n        self.make_override()\n        self.compile_core_plus_ext()\n        self.handle_opencv()\n\n    def install_deps(self):\n        DependencyCompiler.Install(\n            cwd=self.cwd,\n            executable=self.executable,\n            extra_index_url=self.gpuUrl,\n            override=self.override,\n            reqFile=[self.out],\n        )\n\n    def install_dists(self):\n        DependencyCompiler.Install(\n            cwd=self.cwd,\n            executable=self.executable,\n            find_links=[self.outDir / \"dists\"],\n            no_deps=True,\n            no_index=True,\n            reqFile=[self.out],\n        )\n\n    def install_wheels(self):\n        DependencyCompiler.Install(\n            cwd=self.cwd,\n            executable=self.executable,\n            find_links=[self.outDir / \"wheels\"],\n            no_deps=True,\n            no_index=True,\n            reqFile=[self.out],\n        )\n\n    def install_wheels_directly(self):\n        DependencyCompiler.Install(\n            cwd=self.cwd,\n            executable=self.executable,\n            no_deps=True,\n            no_index=True,\n            reqs=(self.outDir / \"wheels\").glob(\"*.whl\"),\n        )\n\n    def sync_core_plus_ext(self):\n        DependencyCompiler.Sync(\n            cwd=self.cwd,\n            reqFile=[self.out],\n            executable=self.executable,\n            extraUrl=self.gpuUrl,\n        )\n\n    def fetch_dep_dists(self, skip_uv: bool = False):\n        skips = [\"uv\"] if skip_uv else None\n        reqs = parse_req_file(self.out, skips=skips)\n\n        extraUrl = None if \"--extra-index-url\" in reqs else self.gpuUrl\n\n        DependencyCompiler.Download(\n            cwd=self.cwd,\n            executable=self.executable,\n            extraUrl=extraUrl,\n            noDeps=True,\n            out=self.outDir / \"dists\",\n            reqs=reqs,\n        )\n\n    def fetch_dep_wheels(self, skip_uv: bool = False):\n        skips = [\"uv\"] if skip_uv else None\n        reqs = parse_req_file(self.out, skips=skips)\n\n        extraUrl = None if \"--extra-index-url\" in reqs else self.gpuUrl\n\n        DependencyCompiler.Wheel(\n            cwd=self.cwd,\n            executable=self.executable,\n            extraUrl=extraUrl,\n            noDeps=True,\n            out=self.outDir / \"wheels\",\n            reqs=reqs,\n        )\n"
  },
  {
    "path": "comfy_cli/workflow_to_api.py",
    "content": "\"\"\"Convert ComfyUI UI-format workflows to API (\"prompt\") format.\n\nThe UI format is what the ComfyUI frontend saves by default — a litegraph dump\nwith `nodes` and `links` arrays. The API format is the flat\n``{node_id: {class_type, inputs, _meta}}`` shape that the server's ``/prompt``\nendpoint accepts.\n\nThe conversion needs schema information about each node type (which inputs are\nwidgets vs connections, what their order is, defaults, combo options, etc.).\nThat information is available from the running server's ``/object_info``\nendpoint — the same data the frontend uses to render the graph editor.\n\nThis module is a Python port of Seth A. Robinson's\n``comfyui-workflow-to-api-converter-endpoint`` (Unlicense), restructured to\ntake a fetched ``object_info`` dict instead of importing ComfyUI's in-process\n``nodes`` module.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport copy\nimport logging\nimport random\nimport re\nfrom typing import Any\n\nlogger = logging.getLogger(__name__)\n\n# C-style comments stripped from dynamic-prompt strings before group parsing.\n_DYNAMIC_PROMPT_COMMENT_RE = re.compile(r\"/\\*[\\s\\S]*?\\*/|//.*\")\n_DYNAMIC_PROMPT_UNESCAPE_RE = re.compile(r\"\\\\([{}|])\")\n\n\n# Mode values from litegraph: see frontend's LGraphEventMode enum.\n_MODE_MUTED = 2  # excluded from execution; outputs not produced\n_MODE_BYPASS = 4  # node skipped; inputs passed through to outputs\n\n# Node types that exist only in the UI graph and never appear in API output.\n# Aligns with cloud-mcp-server's VIRTUAL_NODE_TYPES and the frontend's\n# isVirtualNode set — every type the frontend's graphToPrompt() skips.\n_UI_ONLY_NODE_TYPES = frozenset({\"Note\", \"MarkdownNote\", \"PrimitiveNode\", \"GetNode\", \"SetNode\", \"Reroute\"})\n\n# Sentinel IDs litegraph uses inside a subgraph definition for the synthetic\n# input and output proxy nodes (the boxes the user wires through).\n_SUBGRAPH_INPUT_NODE_ID = -10\n_SUBGRAPH_OUTPUT_NODE_ID = -20\n\n# Cap on recursive subgraph / passthrough resolution to defend against cycles\n# in malformed inputs.\n_MAX_RESOLUTION_DEPTH = 100\n_MAX_SUBGRAPH_ITERATIONS = 10\n\n# Strings that ComfyUI appends after seed-like INT widgets to control how the\n# value changes between runs. They're not real inputs and must be stripped from\n# the widget-value list before mapping to input names.\n_CONTROL_AFTER_GENERATE_VALUES = frozenset({\"fixed\", \"increment\", \"decrement\", \"randomize\"})\n\n\nclass WorkflowConversionError(Exception):\n    \"\"\"Raised when a workflow can't be converted to API format.\"\"\"\n\n\ndef is_api_format(workflow: Any) -> bool:\n    \"\"\"Return True if ``workflow`` already looks like an API-format prompt.\"\"\"\n    if not isinstance(workflow, dict):\n        return False\n    if \"nodes\" in workflow and \"links\" in workflow:\n        return False\n    for key, value in workflow.items():\n        if key in (\"prompt\", \"extra_data\", \"client_id\"):\n            continue\n        if isinstance(value, dict) and \"class_type\" in value:\n            return True\n    return False\n\n\ndef is_subgraph_uuid(node_type: Any) -> bool:\n    \"\"\"A subgraph instance's node ``type`` field is the UUID of a subgraph def.\"\"\"\n    if not isinstance(node_type, str) or len(node_type) != 36:\n        return False\n    parts = node_type.split(\"-\")\n    if len(parts) != 5:\n        return False\n    return tuple(len(p) for p in parts) == (8, 4, 4, 4, 12)\n\n\ndef convert_ui_to_api(workflow: dict, object_info: dict) -> dict:\n    \"\"\"Convert a UI-format workflow to API format.\n\n    Args:\n        workflow: UI workflow with ``nodes`` and ``links`` keys.\n        object_info: ``/object_info`` response: ``{node_type: schema}``.\n\n    Returns:\n        API-format dict: ``{node_id_str: {class_type, inputs, _meta}}``.\n    \"\"\"\n    if is_api_format(workflow):\n        return workflow\n    if not isinstance(workflow, dict):\n        raise WorkflowConversionError(\"Workflow must be a JSON object\")\n    if not isinstance(workflow.get(\"nodes\"), list) or not isinstance(workflow.get(\"links\"), list):\n        raise WorkflowConversionError(\"Workflow is missing 'nodes' or 'links' list\")\n    if not isinstance(object_info, dict):\n        raise WorkflowConversionError(\"object_info must be a JSON object\")\n\n    workflow = copy.deepcopy(workflow)\n    # Discard any non-dict entries up front so the rest of the pipeline doesn't\n    # have to defend against malformed nodes inside the list.\n    nodes = [n for n in workflow[\"nodes\"] if isinstance(n, dict)]\n    links = list(workflow[\"links\"])\n\n    subgraph_defs = _collect_subgraph_defs(workflow)\n    nodes, links, subgraph_ctx = _expand_subgraphs(nodes, links, subgraph_defs)\n\n    links = _rewrite_links_for_subgraphs(links, subgraph_ctx, nodes)\n    link_map = _build_link_map(links)\n\n    node_by_id = {str(n.get(\"id\")): n for n in nodes}\n    primitive_values = _collect_primitive_values(nodes)\n    bypassed = _collect_bypassed(nodes)\n    nodes_to_exclude = _collect_excluded(nodes)\n    reroute_sources = _collect_reroute_sources(nodes, link_map)\n    set_sources, get_vars = _collect_get_set_mappings(nodes, link_map)\n\n    tracers = _Tracers(\n        link_map=link_map,\n        nodes=nodes,\n        node_by_id=node_by_id,\n        bypassed=bypassed,\n        reroute_sources=reroute_sources,\n        set_sources=set_sources,\n        get_vars=get_vars,\n        subgraph_ctx=subgraph_ctx,\n    )\n\n    if _has_group_nodes(workflow):\n        logger.warning(\n            \"Workflow uses legacy 'group nodes' (extra.groupNodes); these aren't \"\n            \"expanded by this converter. Recreate them as subgraphs in the frontend.\"\n        )\n\n    api_prompt: dict[str, dict] = {}\n    for node in nodes:\n        node_id_str = str(node.get(\"id\"))\n        node_type = node.get(\"type\")\n        if not node_type:\n            continue\n        node_mode = node.get(\"mode\", 0)\n        if node_mode in (_MODE_MUTED, _MODE_BYPASS):\n            continue\n        if node_type in _UI_ONLY_NODE_TYPES:\n            continue\n        if node_id_str in nodes_to_exclude:\n            continue\n\n        try:\n            api_prompt[node_id_str] = _build_api_node(\n                node=node,\n                node_type=node_type,\n                object_info=object_info,\n                tracers=tracers,\n                primitive_values=primitive_values,\n                bypassed=bypassed,\n                nodes_to_exclude=nodes_to_exclude,\n            )\n        except Exception:\n            # An individual malformed node should not torpedo the whole prompt.\n            # The executor will fail loudly on missing nodes if this matters.\n            logger.exception(\"Failed to convert node id=%s type=%s; skipping\", node_id_str, node_type)\n\n    _strip_orphan_link_inputs(api_prompt)\n    return api_prompt\n\n\ndef _has_group_nodes(workflow: dict) -> bool:\n    \"\"\"Legacy 'group nodes' (workflow> types) live under extra.groupNodes.\"\"\"\n    extra = workflow.get(\"extra\")\n    if isinstance(extra, dict) and isinstance(extra.get(\"groupNodes\"), dict) and extra[\"groupNodes\"]:\n        return True\n    for node in workflow.get(\"nodes\") or []:\n        if not isinstance(node, dict):\n            continue\n        t = node.get(\"type\")\n        if isinstance(t, str) and (t.startswith(\"workflow>\") or t.startswith(\"workflow/\")):\n            return True\n    return False\n\n\ndef _strip_orphan_link_inputs(api_prompt: dict[str, dict]) -> None:\n    \"\"\"Drop any link inputs that reference a node we didn't emit.\n\n    Defensive mirror of the frontend's final cleanup pass. We already skip\n    most orphans during emission, but a stray reference can survive if the\n    upstream tracing terminated on a node that later got pruned.\n    \"\"\"\n    for node in api_prompt.values():\n        inputs = node.get(\"inputs\")\n        if not isinstance(inputs, dict):\n            continue\n        for name in list(inputs):\n            value = inputs[name]\n            if isinstance(value, list) and len(value) == 2 and isinstance(value[0], str) and value[0] not in api_prompt:\n                del inputs[name]\n\n\n# ---------------------------------------------------------------------------\n# Subgraph handling\n# ---------------------------------------------------------------------------\n\n\nclass _SubgraphCtx:\n    \"\"\"Bookkeeping built during subgraph expansion, used later to rewrite links.\"\"\"\n\n    def __init__(self) -> None:\n        # subgraph_node_id_str -> {subgraph_input_idx: [(internal_node_id, internal_slot), ...]}\n        self.input_targets: dict[str, dict[int, list[tuple[Any, int]]]] = {}\n        # subgraph_node_id_str -> {(internal_node_id, internal_slot): output_slot_idx}\n        self.output_sources: dict[str, dict[tuple[Any, int], int]] = {}\n        # subgraph_node_id_str -> {outer_slot: subgraph_input_idx} (when names differ in order)\n        self.outer_to_input_idx: dict[str, dict[int, int]] = {}\n\n\ndef _collect_subgraph_defs(workflow: dict) -> dict[str, dict]:\n    definitions = workflow.get(\"definitions\")\n    if not isinstance(definitions, dict):\n        return {}\n    subgraphs = definitions.get(\"subgraphs\")\n    if not isinstance(subgraphs, list):\n        return {}\n    defs: dict[str, dict] = {}\n    for sg in subgraphs:\n        if not isinstance(sg, dict):\n            continue\n        sg_id = sg.get(\"id\")\n        # sg_id has to be a string both because we use it as a dict key and\n        # because is_subgraph_uuid (used to match instances) only accepts str.\n        if isinstance(sg_id, str) and sg_id:\n            defs[sg_id] = sg\n    return defs\n\n\ndef _expand_subgraphs(\n    nodes: list[dict], links: list, subgraph_defs: dict[str, dict]\n) -> tuple[list[dict], list, _SubgraphCtx]:\n    \"\"\"Recursively expand subgraph instances into their constituent nodes.\"\"\"\n    ctx = _SubgraphCtx()\n    if not subgraph_defs:\n        return nodes, links, ctx\n\n    for _iteration in range(_MAX_SUBGRAPH_ITERATIONS):\n        expanded: list[dict] = []\n        found_any = False\n        for node in nodes:\n            node_type = node.get(\"type\")\n            if is_subgraph_uuid(node_type) and node_type in subgraph_defs:\n                # Frontend semantics (executionUtil.ts): if the subgraph\n                # instance node itself is muted (mode 2) or bypassed (mode 4),\n                # do NOT pull its inner nodes into the prompt. The instance\n                # stays in the node list where the normal mode-check excludes\n                # it from emission; for bypass, downstream consumers route\n                # through ``trace_bypassed`` on the instance's external\n                # inputs, the same way a bypassed regular node is handled.\n                if node.get(\"mode\") in (_MODE_MUTED, _MODE_BYPASS):\n                    expanded.append(node)\n                    continue\n                found_any = True\n                sg_nodes, sg_links, input_map, output_map = _expand_one_subgraph(node, subgraph_defs[node_type], links)\n                expanded.extend(sg_nodes)\n                links.extend(sg_links)\n                ctx.input_targets[str(node.get(\"id\"))] = input_map\n                ctx.output_sources[str(node.get(\"id\"))] = output_map\n                ctx.outer_to_input_idx[str(node.get(\"id\"))] = _outer_slot_to_input_idx(node, subgraph_defs[node_type])\n            else:\n                expanded.append(node)\n        nodes = expanded\n        if not found_any:\n            return nodes, links, ctx\n\n    logger.warning(\"Subgraph expansion hit iteration cap — possible cyclic reference\")\n    return nodes, links, ctx\n\n\ndef _outer_slot_to_input_idx(outer_node: dict, sg_def: dict) -> dict[int, int]:\n    \"\"\"Map the outer node's input slots to subgraph-definition input indices.\"\"\"\n    sg_input_names: dict[Any, int] = {}\n    for idx, inp in enumerate(sg_def.get(\"inputs\") or []):\n        if isinstance(inp, dict):\n            sg_input_names[inp.get(\"name\")] = idx\n    mapping: dict[int, int] = {}\n    for outer_idx, outer_input in enumerate(outer_node.get(\"inputs\") or []):\n        if not isinstance(outer_input, dict):\n            continue\n        name = outer_input.get(\"name\")\n        if name in sg_input_names:\n            mapping[outer_idx] = sg_input_names[name]\n    return mapping\n\n\ndef _expand_one_subgraph(\n    outer_node: dict, sg_def: dict, existing_links: list\n) -> tuple[list[dict], list, dict[int, list[tuple[Any, int]]], dict[tuple[Any, int], int]]:\n    outer_id = outer_node.get(\"id\")\n    internal_nodes = [n for n in (sg_def.get(\"nodes\") or []) if isinstance(n, dict)]\n    internal_links = sg_def.get(\"links\") or []\n\n    # Subgraph internal link IDs may collide with the outer workflow's IDs.\n    # Allocate fresh IDs starting above the current maximum.\n    max_link_id = 0\n    for link in existing_links:\n        if isinstance(link, (list, tuple)) and link:\n            lid = link[0]\n            if isinstance(lid, int) and lid > max_link_id:\n                max_link_id = lid\n    next_id = max_link_id + 1\n\n    link_id_remap: dict[int, int] = {}\n    internal_link_map: dict[int, dict] = {}\n    for link in internal_links:\n        if not isinstance(link, dict):\n            continue\n        old_id = link.get(\"id\")\n        # Only int IDs are usable here: link_id_remap[old_id] / internal_link_map[old_id]\n        # need a hashable key, and the wider pipeline later does ``link_id in\n        # link_id_remap`` lookups keyed by int link IDs from the outer workflow.\n        # Skip the entry entirely on a missing/unhashable/wrong-typed id so a\n        # bad apple can't crash the whole subgraph expansion (which runs\n        # before the per-node try/except wrapper).\n        if not isinstance(old_id, int):\n            continue\n        link_id_remap[old_id] = next_id\n        next_id += 1\n        internal_link_map[old_id] = link\n\n    input_targets: dict[int, list[tuple[Any, int]]] = {}\n    for idx, in_def in enumerate(sg_def.get(\"inputs\") or []):\n        if not isinstance(in_def, dict):\n            continue\n        targets = []\n        for lid in in_def.get(\"linkIds\") or []:\n            if not isinstance(lid, int):\n                continue\n            link = internal_link_map.get(lid)\n            if isinstance(link, dict):\n                targets.append((link.get(\"target_id\"), link.get(\"target_slot\")))\n        if targets:\n            input_targets[idx] = targets\n\n    output_sources: dict[tuple[Any, int], int] = {}\n    for idx, out_def in enumerate(sg_def.get(\"outputs\") or []):\n        if not isinstance(out_def, dict):\n            continue\n        for lid in out_def.get(\"linkIds\") or []:\n            if not isinstance(lid, int):\n                continue\n            link = internal_link_map.get(lid)\n            if isinstance(link, dict):\n                output_sources[(link.get(\"origin_id\"), link.get(\"origin_slot\"))] = idx\n\n    expanded_nodes: list[dict] = []\n    for inner in internal_nodes:\n        expanded = inner.copy()\n        expanded[\"id\"] = f\"{outer_id}:{inner.get('id')}\"\n        expanded[\"inputs\"] = [\n            _rewrite_internal_input(inp, internal_link_map, link_id_remap) for inp in inner.get(\"inputs\", []) or []\n        ]\n        expanded_nodes.append(expanded)\n\n    expanded_links: list = []\n    for link in internal_links:\n        if not isinstance(link, dict):\n            continue\n        origin_id = link.get(\"origin_id\")\n        target_id = link.get(\"target_id\")\n        if origin_id in (_SUBGRAPH_INPUT_NODE_ID, _SUBGRAPH_OUTPUT_NODE_ID):\n            continue\n        if target_id in (_SUBGRAPH_INPUT_NODE_ID, _SUBGRAPH_OUTPUT_NODE_ID):\n            continue\n        old_id = link.get(\"id\")\n        if not isinstance(old_id, int):\n            continue\n        new_id = link_id_remap.get(old_id, old_id)\n        expanded_links.append(\n            [\n                new_id,\n                f\"{outer_id}:{origin_id}\",\n                link.get(\"origin_slot\"),\n                f\"{outer_id}:{target_id}\",\n                link.get(\"target_slot\"),\n                link.get(\"type\"),\n            ]\n        )\n\n    return expanded_nodes, expanded_links, input_targets, output_sources\n\n\ndef _rewrite_internal_input(\n    input_info: dict, internal_link_map: dict[int, dict], link_id_remap: dict[int, int]\n) -> dict:\n    input_copy = input_info.copy()\n    link_id = input_info.get(\"link\")\n    if not isinstance(link_id, int):\n        # Both internal_link_map and link_id_remap are keyed by int IDs; an\n        # unhashable (list/dict) link_id would otherwise crash the lookup\n        # and abort the whole subgraph expansion.\n        return input_copy\n    link = internal_link_map.get(link_id)\n    if not isinstance(link, dict):\n        return input_copy\n    if link.get(\"origin_id\") == _SUBGRAPH_INPUT_NODE_ID:\n        # Will be reattached to an external link by _rewrite_links_for_subgraphs.\n        input_copy[\"link\"] = None\n    elif link_id in link_id_remap:\n        input_copy[\"link\"] = link_id_remap[link_id]\n    return input_copy\n\n\ndef _rewrite_links_for_subgraphs(links: list, ctx: _SubgraphCtx, nodes: list[dict]) -> list:\n    \"\"\"Resolve links that cross subgraph boundaries to their internal endpoints.\"\"\"\n    if not ctx.output_sources and not ctx.input_targets:\n        return links\n\n    node_input_updates: dict[str, dict[int, int]] = {}\n    updated: list = []\n    for link in links:\n        if not isinstance(link, (list, tuple)) or len(link) < 6:\n            updated.append(link)\n            continue\n        link_id, src_id, src_slot, tgt_id, tgt_slot, link_type = link[:6]\n\n        src_id_str = str(src_id)\n        src_id_out, src_slot_out = _resolve_subgraph_output(src_id_str, src_slot, ctx)\n\n        tgt_id_str = str(tgt_id)\n        all_targets = _resolve_subgraph_input_all(tgt_id_str, tgt_slot, ctx)\n        # Track input-slot rewrites for ALL targets (one outer input may fan out).\n        for resolved_tgt_id, resolved_tgt_slot in all_targets:\n            if resolved_tgt_id != tgt_id_str:\n                node_input_updates.setdefault(resolved_tgt_id, {})[resolved_tgt_slot] = link_id\n\n        first_tgt_id, first_tgt_slot = all_targets[0]\n        updated.append([link_id, src_id_out, src_slot_out, first_tgt_id, first_tgt_slot, link_type])\n\n    # Apply input updates to the expanded internal nodes.\n    for node in nodes:\n        node_id_str = str(node.get(\"id\"))\n        if node_id_str not in node_input_updates:\n            continue\n        slot_to_link = node_input_updates[node_id_str]\n        for slot_idx, input_info in enumerate(node.get(\"inputs\", []) or []):\n            if slot_idx in slot_to_link:\n                input_info[\"link\"] = slot_to_link[slot_idx]\n\n    return updated\n\n\ndef _resolve_subgraph_output(node_id_str: str, slot: Any, ctx: _SubgraphCtx, depth: int = 0) -> tuple[Any, Any]:\n    if depth > _MAX_RESOLUTION_DEPTH:\n        return node_id_str, slot\n    mapping = ctx.output_sources.get(node_id_str)\n    if not mapping:\n        return node_id_str, slot\n    for (internal_node, internal_slot), out_slot in mapping.items():\n        if out_slot == slot:\n            new_id = f\"{node_id_str}:{internal_node}\"\n            return _resolve_subgraph_output(new_id, internal_slot, ctx, depth + 1)\n    return node_id_str, slot\n\n\ndef _resolve_subgraph_input_all(\n    node_id_str: str, slot: Any, ctx: _SubgraphCtx, depth: int = 0\n) -> list[tuple[Any, Any]]:\n    if depth > _MAX_RESOLUTION_DEPTH:\n        return [(node_id_str, slot)]\n    mapping = ctx.input_targets.get(node_id_str)\n    if not mapping:\n        return [(node_id_str, slot)]\n\n    sg_input_idx = slot\n    outer_map = ctx.outer_to_input_idx.get(node_id_str)\n    if outer_map and slot in outer_map:\n        sg_input_idx = outer_map[slot]\n\n    targets = mapping.get(sg_input_idx)\n    if not targets:\n        return [(node_id_str, slot)]\n\n    out: list[tuple[Any, Any]] = []\n    for internal_node, internal_slot in targets:\n        new_id = f\"{node_id_str}:{internal_node}\"\n        out.extend(_resolve_subgraph_input_all(new_id, internal_slot, ctx, depth + 1))\n    return out or [(node_id_str, slot)]\n\n\n# ---------------------------------------------------------------------------\n# Link map + tracing helpers\n# ---------------------------------------------------------------------------\n\n\ndef _is_valid_connection(type_a: Any, type_b: Any) -> bool:\n    \"\"\"Mirror of LiteGraph.isValidConnection from the frontend.\n\n    ``*`` and ``\"\"`` wildcards match anything; comma-separated alternatives are\n    expanded; otherwise we case-insensitively compare type names.\n    \"\"\"\n    if type_a in (0, \"\", \"*\"):\n        type_a = 0\n    if type_b in (0, \"\", \"*\"):\n        type_b = 0\n    if not type_a or not type_b or type_a == type_b:\n        return True\n    type_a_s = str(type_a).lower()\n    type_b_s = str(type_b).lower()\n    if \",\" not in type_a_s and \",\" not in type_b_s:\n        return type_a_s == type_b_s\n    for a in type_a_s.split(\",\"):\n        for b in type_b_s.split(\",\"):\n            if _is_valid_connection(a.strip(), b.strip()):\n                return True\n    return False\n\n\ndef _build_link_map(links: list) -> dict[int, dict]:\n    link_map: dict[int, dict] = {}\n    for link in links:\n        if not isinstance(link, (list, tuple)) or len(link) < 6:\n            continue\n        link_id, src_id, src_slot, tgt_id, tgt_slot, link_type = link[:6]\n        link_map[link_id] = {\n            \"source_id\": src_id,\n            \"source_slot\": src_slot,\n            \"target_id\": tgt_id,\n            \"target_slot\": tgt_slot,\n            \"type\": link_type,\n        }\n    return link_map\n\n\ndef _collect_primitive_values(nodes: list[dict]) -> dict[str, Any]:\n    out: dict[str, Any] = {}\n    for node in nodes:\n        if node.get(\"type\") != \"PrimitiveNode\":\n            continue\n        widgets = node.get(\"widgets_values\")\n        if isinstance(widgets, list) and widgets:\n            out[str(node.get(\"id\"))] = widgets[0]\n    return out\n\n\ndef _collect_bypassed(nodes: list[dict]) -> set[str]:\n    return {str(n.get(\"id\")) for n in nodes if n.get(\"mode\") == _MODE_BYPASS}\n\n\ndef _collect_reroute_sources(nodes: list[dict], link_map: dict[int, dict]) -> dict[str, tuple[Any, Any]]:\n    out: dict[str, tuple[Any, Any]] = {}\n    for node in nodes:\n        if node.get(\"type\") != \"Reroute\":\n            continue\n        inputs = node.get(\"inputs\")\n        if not isinstance(inputs, list) or not inputs or not isinstance(inputs[0], dict):\n            continue\n        link_id = inputs[0].get(\"link\")\n        # ``link_id in link_map`` raises TypeError on unhashable values\n        # (e.g. ``link: []`` in a malformed saved file). _collect_reroute_sources\n        # runs before the per-node try/except wrapper, so a single bad Reroute\n        # would otherwise abort the entire conversion.\n        if not isinstance(link_id, int) or link_id not in link_map:\n            continue\n        ld = link_map[link_id]\n        out[str(node.get(\"id\"))] = (ld[\"source_id\"], ld[\"source_slot\"])\n    return out\n\n\ndef _collect_get_set_mappings(\n    nodes: list[dict], link_map: dict[int, dict]\n) -> tuple[dict[str, tuple[Any, Any]], dict[str, str]]:\n    \"\"\"SetNode publishes a value under a name; GetNode reads it back.\"\"\"\n    set_sources: dict[str, tuple[Any, Any]] = {}\n    get_vars: dict[str, str] = {}\n    for node in nodes:\n        node_type = node.get(\"type\")\n        widgets = node.get(\"widgets_values\")\n        if not isinstance(widgets, list) or not widgets:\n            continue\n        var_name = widgets[0]\n        # var_name becomes a dict key (set_sources[var_name]) and is later\n        # checked with ``var_name in set_sources`` inside the tracer. Both\n        # require it to be a non-empty string; reject anything else early.\n        if not isinstance(var_name, str) or not var_name:\n            continue\n        if node_type == \"SetNode\":\n            for inp in node.get(\"inputs\") or []:\n                if not isinstance(inp, dict):\n                    continue\n                lid = inp.get(\"link\")\n                # See _collect_reroute_sources: unhashable lid would crash\n                # the global pre-pass before any per-node guard kicks in.\n                if not isinstance(lid, int) or lid not in link_map:\n                    continue\n                ld = link_map[lid]\n                set_sources[var_name] = (ld[\"source_id\"], ld[\"source_slot\"])\n                break\n        elif node_type == \"GetNode\":\n            get_vars[str(node.get(\"id\"))] = var_name\n    return set_sources, get_vars\n\n\ndef _collect_excluded(nodes: list[dict]) -> set[str]:\n    \"\"\"Identify nodes that should never appear in the API output.\n\n    Only ``LoadImageOutput`` is excluded here — it's a UI-only file picker\n    for browsing the output folder, with no Python class behind it. All\n    other UI-only types are filtered by name via ``_UI_ONLY_NODE_TYPES``.\n\n    Matches the frontend's policy (``executionUtil.ts:graphToPrompt``) and\n    cloud-mcp-server's ``shouldIncludeInOutput`` of emitting every\n    non-virtual, non-muted, non-bypassed node regardless of whether its\n    outputs are wired. The executor only runs nodes reachable from sinks\n    (SaveImage, etc.), so unwired nodes are harmless in the prompt.\n\n    We previously applied a \"dead-branch\" heuristic that dropped any node\n    with no downstream consumer; that excluded legitimate sources like an\n    unwired ``LoadAudio`` and caused 20+ cloud-mcp oracle fixtures to lose\n    nodes that the live frontend emits.\n    \"\"\"\n    return {str(n.get(\"id\")) for n in nodes if n.get(\"type\") == \"LoadImageOutput\"}\n\n\nclass _Tracers:\n    \"\"\"Bundle of upstream-resolution helpers used while emitting each API node.\"\"\"\n\n    def __init__(\n        self,\n        *,\n        link_map: dict[int, dict],\n        nodes: list[dict],\n        node_by_id: dict[str, dict],\n        bypassed: set[str],\n        reroute_sources: dict[str, tuple[Any, Any]],\n        set_sources: dict[str, tuple[Any, Any]],\n        get_vars: dict[str, str],\n        subgraph_ctx: _SubgraphCtx,\n    ) -> None:\n        self.link_map = link_map\n        self.nodes = nodes\n        self.node_by_id = node_by_id\n        self.bypassed = bypassed\n        self.reroute_sources = reroute_sources\n        self.set_sources = set_sources\n        self.get_vars = get_vars\n        self.subgraph_ctx = subgraph_ctx\n\n    def trace_reroute(self, src_id: Any, src_slot: Any) -> tuple[Any, Any]:\n        # Iterative to avoid Python's recursion limit on long chains. The\n        # body matches a tail-recursive version exactly; the seen-set guards\n        # against cyclic ``Reroute -> Reroute -> ...`` loops.\n        seen: set[str] = set()\n        while True:\n            key = str(src_id)\n            if key in seen or key not in self.reroute_sources:\n                return src_id, src_slot\n            seen.add(key)\n            src_id, src_slot = self.reroute_sources[key]\n\n    def trace_get_set(self, src_id: Any, src_slot: Any) -> tuple[Any, Any]:\n        # Same iterative shape as trace_reroute. Hops through one\n        # GetNode -> SetNode pair per step; the seen-set guards against\n        # cycles via repeated variable names.\n        seen: set[str] = set()\n        while True:\n            key = str(src_id)\n            if key in seen or key not in self.get_vars:\n                return src_id, src_slot\n            seen.add(key)\n            var_name = self.get_vars[key]\n            if var_name not in self.set_sources:\n                return src_id, src_slot\n            src_id, src_slot = self.set_sources[var_name]\n\n    def trace_bypassed(self, src_id: Any, src_slot: Any) -> tuple[Any, Any]:\n        # Iterative. Each loop iteration corresponds to walking through one\n        # bypassed node; inner calls to trace_get_set / trace_reroute already\n        # iterate over their respective chains (no recursion).\n        seen: set[Any] = set()\n        while True:\n            if src_id in seen:\n                return src_id, src_slot\n            seen.add(src_id)\n            if str(src_id) not in self.bypassed:\n                return src_id, src_slot\n\n            node = self.node_by_id.get(str(src_id))\n            if not node:\n                return src_id, src_slot\n\n            outputs = node.get(\"outputs\") or []\n            # Guard the slot index — malformed workflows can have non-numeric slots.\n            try:\n                slot_idx = int(src_slot) if src_slot is not None else 0\n            except (TypeError, ValueError):\n                slot_idx = 0\n            output_type = (\n                outputs[slot_idx].get(\"type\")\n                if 0 <= slot_idx < len(outputs) and isinstance(outputs[slot_idx], dict)\n                else None\n            )\n\n            # Pick the input we'll forward the output through. We mix the frontend's\n            # strict matcher (ExecutableNodeDTO._getBypassSlotIndex) with the\n            # reference converter's permissive fallback, in order of preference:\n            #   1. Same-slot input if its type connects to the output type\n            #   2. First input whose type matches the output type exactly\n            #   3. First input whose type is connection-compatible (handles ``*``\n            #      and ``,``-separated alternatives via LiteGraph.isValidConnection)\n            #   4. First linked input regardless of type — preserves user intent\n            #      when types disagree, matching SethRobinson's reference. The\n            #      executor will surface a type mismatch loudly if it matters.\n            inputs = node.get(\"inputs\") or []\n            chosen_link: int | None = None\n            exact_link: int | None = None\n            compat_link: int | None = None\n            fallback_link: int | None = None\n\n            same_slot_inp = (\n                inputs[slot_idx] if 0 <= slot_idx < len(inputs) and isinstance(inputs[slot_idx], dict) else None\n            )\n            if same_slot_inp:\n                lid = same_slot_inp.get(\"link\")\n                if (\n                    lid is not None\n                    and lid in self.link_map\n                    and _is_valid_connection(same_slot_inp.get(\"type\"), output_type)\n                ):\n                    chosen_link = lid\n\n            if chosen_link is None:\n                for inp in inputs:\n                    if not isinstance(inp, dict):\n                        continue\n                    lid = inp.get(\"link\")\n                    if lid is None or lid not in self.link_map:\n                        continue\n                    inp_type = inp.get(\"type\")\n                    if fallback_link is None:\n                        fallback_link = lid\n                    if output_type and inp_type == output_type and exact_link is None:\n                        exact_link = lid\n                    if compat_link is None and _is_valid_connection(inp_type, output_type):\n                        compat_link = lid\n                chosen_link = exact_link if exact_link is not None else compat_link\n                if chosen_link is None:\n                    chosen_link = fallback_link\n\n            if chosen_link is None:\n                return src_id, src_slot\n\n            ld = self.link_map[chosen_link]\n            upstream_id, upstream_slot = ld[\"source_id\"], ld[\"source_slot\"]\n            upstream_id, upstream_slot = self.trace_get_set(upstream_id, upstream_slot)\n            upstream_id, upstream_slot = self.trace_reroute(upstream_id, upstream_slot)\n            src_id, src_slot = upstream_id, upstream_slot\n            # Loop continues with the new src_id/src_slot.\n\n\n# ---------------------------------------------------------------------------\n# Per-node emission\n# ---------------------------------------------------------------------------\n\n\ndef _wrap_widget_value(value: Any) -> Any:\n    \"\"\"Wrap list widget values to disambiguate them from [node_id, slot] links.\n\n    ComfyUI's executor strips the wrapper before passing to the node. See\n    execution.py: ``if \"__value__\" in val: val = val[\"__value__\"]``.\n    \"\"\"\n    if isinstance(value, list):\n        return {\"__value__\": value}\n    return value\n\n\ndef process_dynamic_prompt(value: str) -> str:\n    \"\"\"Resolve the ``{a|b|c}`` syntax used in CLIPTextEncode text widgets.\n\n    Port of the frontend's ``processDynamicPrompt`` (``formatUtil.ts``):\n\n    * Strips ``/* ... */`` and ``// ...`` comments first.\n    * Picks one alternative at random from each top-level ``{a|b|...}``\n      group. Nested groups are recursed into after a choice is made.\n    * ``\\\\{``, ``\\\\}``, ``\\\\|`` escape their literal characters.\n\n    Non-deterministic by design — the backend doesn't process the syntax,\n    so a workflow saved with ``{red|blue} hat`` would otherwise tokenize\n    the braces literally and produce a junk image.\n    \"\"\"\n    return _resolve_dynamic_prompt(_DYNAMIC_PROMPT_COMMENT_RE.sub(\"\", value))\n\n\ndef _resolve_dynamic_prompt(value: str) -> str:\n    out: list[str] = []\n    i = 0\n    n = len(value)\n    while i < n:\n        ch = value[i]\n        i += 1\n        if ch == \"\\\\\" and i < n:\n            # Preserve the escape marker so the unescape pass at the end can\n            # restore the literal character without it being consumed earlier.\n            out.append(\"\\\\\" + value[i])\n            i += 1\n        elif ch == \"{\":\n            chosen, i = _parse_dynamic_prompt_block(value, i)\n            out.append(_resolve_dynamic_prompt(chosen))\n        else:\n            out.append(ch)\n    return _DYNAMIC_PROMPT_UNESCAPE_RE.sub(r\"\\1\", \"\".join(out))\n\n\ndef _parse_dynamic_prompt_block(value: str, i: int) -> tuple[str, int]:\n    \"\"\"Parse a ``{a|b|...}`` group starting at index ``i`` (just past the ``{``).\n\n    Returns ``(chosen_option, new_i)``. ``new_i`` points past the closing\n    ``}`` (or past end-of-string if the group is unterminated — the frontend\n    silently degrades on malformed input and we match that).\n    \"\"\"\n    options: list[str] = []\n    choice: list[str] = []\n    depth = 0\n    n = len(value)\n    while i < n:\n        ch = value[i]\n        i += 1\n        if ch == \"\\\\\" and i < n:\n            choice.append(\"\\\\\" + value[i])\n            i += 1\n            continue\n        if ch == \"{\":\n            depth += 1\n            choice.append(ch)\n        elif ch == \"}\":\n            if depth == 0:\n                break\n            depth -= 1\n            choice.append(ch)\n        elif ch == \"|\" and depth == 0:\n            options.append(\"\".join(choice))\n            choice = []\n        else:\n            choice.append(ch)\n    options.append(\"\".join(choice))\n    return random.choice(options), i\n\n\ndef _dynamic_prompt_input_names(node_type: str | None, node: dict | None, object_info: dict) -> set[str]:\n    \"\"\"Names of inputs whose schema declares ``dynamicPrompts: True``.\"\"\"\n    if not node_type or not node:\n        return set()\n    schema = _schema_for(node_type, node, object_info)\n    if not schema:\n        return set()\n    input_def = _schema_input_def(schema)\n    out: set[str] = set()\n    for section in (\"required\", \"optional\"):\n        section_def = input_def.get(section) or {}\n        if not isinstance(section_def, dict):\n            continue\n        for input_name, input_spec in section_def.items():\n            if not isinstance(input_spec, (list, tuple)) or len(input_spec) < 2:\n                continue\n            options = input_spec[1] if isinstance(input_spec[1], dict) else {}\n            if options.get(\"dynamicPrompts\"):\n                out.add(input_name)\n    return out\n\n\ndef _build_api_node(\n    *,\n    node: dict,\n    node_type: str,\n    object_info: dict,\n    tracers: _Tracers,\n    primitive_values: dict[str, Any],\n    bypassed: set[str],\n    nodes_to_exclude: set[str],\n) -> dict:\n    api_node: dict = {\"inputs\": {}, \"class_type\": node_type}\n    # Resolve the schema once via _schema_for so every consumer\n    # (_meta.title, defaults, combo normalization) sees the same thing\n    # as the widget-mapping path, even on nodes that carry a ``Node name\n    # for S&R`` property pointing at a different class.\n    schema = _schema_for(node_type, node, object_info) or {}\n\n    if \"title\" in node:\n        api_node[\"_meta\"] = {\"title\": node[\"title\"]}\n    else:\n        api_node[\"_meta\"] = {\"title\": schema.get(\"display_name\") or node_type}\n\n    link_inputs: dict[str, list] = {}\n    primitive_inputs: dict[str, Any] = {}\n    for inp in node.get(\"inputs\") or []:\n        if not isinstance(inp, dict):\n            continue\n        input_name = inp.get(\"name\")\n        link_id = inp.get(\"link\")\n        if not input_name or not isinstance(link_id, int) or link_id not in tracers.link_map:\n            continue\n        ld = tracers.link_map[link_id]\n        actual_id, actual_slot = ld[\"source_id\"], ld[\"source_slot\"]\n\n        actual_id, actual_slot = tracers.trace_get_set(actual_id, actual_slot)\n        actual_id, actual_slot = tracers.trace_reroute(actual_id, actual_slot)\n        if str(actual_id) in bypassed:\n            actual_id, actual_slot = tracers.trace_bypassed(actual_id, actual_slot)\n            if str(actual_id) in bypassed:\n                # Couldn't find a non-bypassed source — let widget default cover it.\n                continue\n        # Bypassed source may itself have referenced a GetNode or Reroute.\n        actual_id, actual_slot = tracers.trace_get_set(actual_id, actual_slot)\n        actual_id, actual_slot = tracers.trace_reroute(actual_id, actual_slot)\n        # If we crossed a subgraph boundary while tracing, finalize to internal node.\n        actual_id, actual_slot = _resolve_subgraph_output(str(actual_id), actual_slot, tracers.subgraph_ctx)\n\n        actual_id_str = str(actual_id)\n        if actual_id_str in primitive_values:\n            primitive_inputs[input_name] = _wrap_widget_value(primitive_values[actual_id_str])\n        elif actual_id_str in nodes_to_exclude:\n            continue\n        elif actual_id_str in bypassed:\n            continue\n        else:\n            link_inputs[input_name] = [actual_id_str, actual_slot]\n\n    widget_inputs = _collect_widget_inputs(node, node_type, object_info, link_inputs)\n    default_inputs = _collect_default_inputs(schema, widget_inputs, primitive_inputs, link_inputs)\n\n    ordered = _get_ordered_input_names(node_type, node, object_info)\n    if ordered:\n        # First widget-like values in the declared order, then link inputs.\n        # This matches what ComfyUI's \"Save (API)\" produces.\n        for name in ordered:\n            if name in widget_inputs:\n                api_node[\"inputs\"][name] = widget_inputs[name]\n            elif name in primitive_inputs:\n                api_node[\"inputs\"][name] = primitive_inputs[name]\n            elif name in default_inputs:\n                api_node[\"inputs\"][name] = default_inputs[name]\n        for name in ordered:\n            if name in link_inputs and name not in api_node[\"inputs\"]:\n                api_node[\"inputs\"][name] = link_inputs[name]\n\n    # Anything we didn't know an order for is still emitted (preserves data).\n    for source in (widget_inputs, primitive_inputs, default_inputs, link_inputs):\n        for key, value in source.items():\n            if key not in api_node[\"inputs\"]:\n                api_node[\"inputs\"][key] = value\n\n    _normalize_combo_values(schema, api_node[\"inputs\"])\n    return api_node\n\n\n# ---------------------------------------------------------------------------\n# Widget / input order helpers (driven by /object_info)\n# ---------------------------------------------------------------------------\n\n\ndef _schema_for(node_type: str, node: dict, object_info: dict) -> dict | None:\n    # Some nodes (litegraph subgraphs) store the real class name under properties.\n    properties = node.get(\"properties\") or {}\n    alt_name = properties.get(\"Node name for S&R\")\n    if isinstance(alt_name, str) and alt_name in object_info:\n        return object_info[alt_name]\n    return object_info.get(node_type) if isinstance(node_type, str) else None\n\n\ndef _schema_input_def(schema: Any) -> dict:\n    \"\"\"Return the schema's ``input`` block as a dict, or ``{}`` if absent/malformed.\n\n    Every helper that walks INPUT_TYPES sections needs this guard: the raw\n    ``schema.get(\"input\") or {}`` pattern returns the value as-is when it's\n    truthy, so a malformed schema with ``\"input\": [...]`` would later crash\n    on ``.get(section)``. In practice ``/object_info`` never sends a non-dict\n    here, but the rest of the converter follows the same defensive contract.\n    \"\"\"\n    if not isinstance(schema, dict):\n        return {}\n    input_def = schema.get(\"input\")\n    return input_def if isinstance(input_def, dict) else {}\n\n\ndef _get_ordered_input_names(node_type: str, node: dict, object_info: dict) -> list[str]:\n    schema = _schema_for(node_type, node, object_info)\n    if not schema:\n        return []\n    input_order = schema.get(\"input_order\")\n    if not isinstance(input_order, dict):\n        input_order = {}\n    out: list[str] = []\n    for section in (\"required\", \"optional\"):\n        section_order = input_order.get(section)\n        if isinstance(section_order, list):\n            out.extend(section_order)\n    if out:\n        return out\n    # Fall back to whatever order is in the input dict itself.\n    input_def = _schema_input_def(schema)\n    for section in (\"required\", \"optional\"):\n        section_def = input_def.get(section) or {}\n        if isinstance(section_def, dict):\n            out.extend(section_def.keys())\n    return out\n\n\ndef _is_widget_input(input_spec: Any) -> tuple[bool, bool]:\n    \"\"\"Return (is_widget, is_dynamic_combo) for an INPUT_TYPES spec.\"\"\"\n    if not isinstance(input_spec, (list, tuple)) or not input_spec:\n        return False, False\n    # ``forceInput: True`` (legacy alias: ``defaultInput``) explicitly demotes\n    # a widget-type input to a connection-only slot; the frontend doesn't\n    # render a widget for it and the saved workflow has no value for it in\n    # widgets_values. Treating it as a widget here would consume a value-slot\n    # that doesn't exist and shift every later widget out of position.\n    options = input_spec[1] if len(input_spec) >= 2 and isinstance(input_spec[1], dict) else {}\n    if options.get(\"forceInput\") or options.get(\"defaultInput\"):\n        return False, False\n    input_type = input_spec[0]\n    if isinstance(input_type, (list, tuple)):\n        return True, False  # combo of choices\n    if isinstance(input_type, str):\n        # ``*`` and ``\"\"`` are wildcard *connection* types — the frontend\n        # never renders a widget for them. They slipped through the\n        # lowercase fallback below because they have no cased characters\n        # (``\"*\".isupper()`` returns ``False``), so we have to filter them\n        # out explicitly. PreviewAny.source: [\"*\", {}] is the canonical\n        # case this used to mis-handle.\n        if input_type in (\"\", \"*\"):\n            return False, False\n        if input_type in {\"INT\", \"FLOAT\", \"STRING\", \"BOOLEAN\", \"COMBO\"}:\n            return True, False\n        if input_type.startswith(\"COMFY_\") and \"COMBO\" in input_type:\n            return True, True\n        if not input_type.isupper():\n            return True, False  # custom (lowercase) widget types\n    return False, False\n\n\ndef _dynamic_combo_sub_inputs(\n    input_name: str, input_spec: Any, widget_values: list[Any], current_idx: int\n) -> list[str]:\n    if not isinstance(input_spec, (list, tuple)) or len(input_spec) < 2:\n        return []\n    options_meta = input_spec[1] if isinstance(input_spec[1], dict) else {}\n    options = options_meta.get(\"options\") or []\n    if not options or current_idx >= len(widget_values):\n        return []\n    selected = widget_values[current_idx]\n    for option in options:\n        if not isinstance(option, dict) or option.get(\"key\") != selected:\n            continue\n        sub_def = option.get(\"inputs\")\n        # The option's ``inputs`` is supposed to mirror an INPUT_TYPES dict\n        # (``{\"required\": {...}, \"optional\": {...}}``). Treat anything else\n        # — typically a malformed third-party V3 node — as having no\n        # sub-inputs rather than letting AttributeError escape into the\n        # per-node wrapper and silently dropping the whole node.\n        if not isinstance(sub_def, dict):\n            return []\n        names: list[str] = []\n        for section in (\"required\", \"optional\"):\n            section_def = sub_def.get(section) or {}\n            if isinstance(section_def, dict):\n                names.extend(f\"{input_name}.{sub_name}\" for sub_name in section_def.keys())\n        return names\n    return []\n\n\ndef _get_widget_name_order(node_type: str, node: dict, object_info: dict, widget_values: list[Any]) -> list[str | None]:\n    \"\"\"Build the widget-name list that maps positionally to ``widgets_values``.\"\"\"\n    schema = _schema_for(node_type, node, object_info)\n    if schema:\n        input_def = _schema_input_def(schema)\n        names: list[str | None] = []\n        widget_idx = 0\n        for section in (\"required\", \"optional\"):\n            section_def = input_def.get(section) or {}\n            if not isinstance(section_def, dict):\n                continue\n            for input_name, input_spec in section_def.items():\n                is_widget, is_dynamic = _is_widget_input(input_spec)\n                if not is_widget:\n                    continue\n                names.append(input_name)\n                if is_dynamic and widget_values:\n                    subs = _dynamic_combo_sub_inputs(input_name, input_spec, widget_values, widget_idx)\n                    names.extend(subs)\n                    widget_idx += 1 + len(subs)\n                else:\n                    widget_idx += 1\n        if names:\n            return names\n\n    # Fallback: inspect the node's own input list. Some nodes mark widget-flagged inputs.\n    return _fallback_widget_names(node, widget_values)\n\n\ndef _fallback_widget_names(node: dict, widget_values: list[Any]) -> list[str | None]:\n    properties = node.get(\"properties\") or {}\n    ue_properties = properties.get(\"ue_properties\") or {}\n    ue_connectable = ue_properties.get(\"widget_ue_connectable\")\n    if isinstance(ue_connectable, dict) and ue_connectable:\n        names = list(ue_connectable.keys())\n        if len(names) >= len(widget_values):\n            return list(names[: len(widget_values)])\n\n    all_inputs: list[str] = []\n    connected: set[str] = set()\n    widget_flagged: list[str] = []\n    for inp in node.get(\"inputs\") or []:\n        if not isinstance(inp, dict):\n            continue\n        name = inp.get(\"name\")\n        if not name:\n            continue\n        all_inputs.append(name)\n        if inp.get(\"link\") is not None:\n            connected.add(name)\n        if inp.get(\"widget\"):\n            widget_flagged.append(name)\n\n    if widget_flagged:\n        if len(widget_values) > len(widget_flagged):\n            extras = [n for n in all_inputs if n not in connected and n not in widget_flagged]\n            return widget_flagged + extras[: len(widget_values) - len(widget_flagged)]\n        return list(widget_flagged)\n\n    unconnected = [n for n in all_inputs if n not in connected]\n    if len(unconnected) >= len(widget_values):\n        return unconnected[: len(widget_values)]\n    return []\n\n\ndef _filter_control_values(\n    widget_values: list[Any],\n    node_type: str | None = None,\n    node: dict | None = None,\n    object_info: dict | None = None,\n) -> list[Any]:\n    \"\"\"Drop the control_after_generate strings that follow seed-like INT widgets.\n\n    Schema-aware when a schema is available: only a string immediately\n    following an input that declares ``control_after_generate: True`` is\n    treated as a control marker. This avoids false positives on legitimate\n    STRING/COMBO widget values that happen to equal one of the control\n    keywords (e.g. a combo option literally named ``\"fixed\"``).\n\n    Falls back to a positional string-match heuristic when the schema is\n    unavailable — matches SethRobinson's behavior for unknown node types.\n    \"\"\"\n\n    def is_control(v: Any) -> bool:\n        return isinstance(v, str) and v in _CONTROL_AFTER_GENERATE_VALUES\n\n    schema = _schema_for(node_type, node, object_info) if node_type and node and object_info else None\n    if not schema:\n        out: list[Any] = []\n        i = 0\n        while i < len(widget_values):\n            value = widget_values[i]\n            if is_control(value):\n                i += 1\n                continue\n            if i + 1 < len(widget_values) and is_control(widget_values[i + 1]):\n                out.append(value)\n                i += 2\n                continue\n            out.append(value)\n            i += 1\n        return out\n\n    out = []\n    vidx = 0\n    input_def = _schema_input_def(schema)\n    for section in (\"required\", \"optional\"):\n        section_def = input_def.get(section) or {}\n        if not isinstance(section_def, dict):\n            continue\n        for input_name, input_spec in section_def.items():\n            if vidx >= len(widget_values):\n                break\n            is_widget, _is_dynamic = _is_widget_input(input_spec)\n            if not is_widget:\n                continue\n            out.append(widget_values[vidx])\n            vidx += 1\n            if vidx < len(widget_values) and _has_control_after_generate_companion(\n                input_name, input_spec, widget_values[vidx]\n            ):\n                vidx += 1\n    while vidx < len(widget_values):\n        out.append(widget_values[vidx])\n        vidx += 1\n    return out\n\n\ndef _has_control_after_generate_companion(input_name: str, input_spec: Any, next_value: Any) -> bool:\n    \"\"\"True if ``next_value`` should be consumed as a control_after_generate marker.\n\n    Two ways the frontend adds the companion widget:\n\n    * Explicit: the input spec sets ``control_after_generate: True``.\n    * Implicit: the input is named ``seed`` or ``noise_seed`` and is INT-typed.\n      The frontend's ``useIntWidget`` composable adds the companion in that case\n      regardless of the schema flag.\n\n    For the implicit path we peek at the next value: older workflows saved\n    before the companion existed don't have the marker string, so we must\n    verify the slot really is a control keyword before consuming it.\n    \"\"\"\n    options = input_spec[1] if len(input_spec) >= 2 and isinstance(input_spec[1], dict) else {}\n    if options.get(\"control_after_generate\"):\n        return isinstance(next_value, str) and next_value in _CONTROL_AFTER_GENERATE_VALUES\n    input_type = input_spec[0] if input_spec else None\n    if input_type == \"INT\" and input_name in (\"seed\", \"noise_seed\"):\n        return isinstance(next_value, str) and next_value in _CONTROL_AFTER_GENERATE_VALUES\n    return False\n\n\ndef _collect_widget_inputs(\n    node: dict, node_type: str, object_info: dict, link_inputs: dict[str, list]\n) -> dict[str, Any]:\n    widget_values = node.get(\"widgets_values\")\n    if widget_values is None:\n        return {}\n    dynamic_prompt_names = _dynamic_prompt_input_names(node_type, node, object_info)\n\n    def emit(name: str, value: Any) -> Any:\n        if name in dynamic_prompt_names and isinstance(value, str):\n            value = process_dynamic_prompt(value)\n        return _wrap_widget_value(value)\n\n    out: dict[str, Any] = {}\n    if isinstance(widget_values, dict):\n        # Already self-describing; drop UI-only keys and respect link overrides.\n        for key, value in widget_values.items():\n            if key in (\"videopreview\", \"preview\"):\n                continue\n            if key in link_inputs:\n                continue\n            out[key] = emit(key, value)\n        return out\n    if not isinstance(widget_values, list):\n        return {}\n\n    if any(isinstance(v, dict) for v in widget_values):\n        _absorb_dict_widget_values(widget_values, out, link_inputs)\n        return out\n\n    filtered = _filter_control_values(widget_values, node_type, node, object_info)\n    # ``widget_idx`` inside _get_widget_name_order is the position in the\n    # value list it receives, so it must see the *filtered* list — otherwise\n    # a V3 dynamic combo's selector is read from the wrong slot whenever a\n    # control_after_generate marker precedes it (e.g. on the Bria / Kling /\n    # Vidu / Wan2 API nodes that pair a seed with a dynamic combo).\n    names = _get_widget_name_order(node_type, node, object_info, filtered)\n    if not names:\n        if filtered:\n            logger.warning(\n                \"Could not map widget_values for unknown node type %r (node %s)\",\n                node_type,\n                node.get(\"id\"),\n            )\n        return out\n    for i, value in enumerate(filtered):\n        if i >= len(names):\n            break\n        name = names[i]\n        if not name or name in link_inputs:\n            continue\n        out[name] = emit(name, value)\n    return out\n\n\ndef _absorb_dict_widget_values(widget_values: list[Any], out: dict[str, Any], link_inputs: dict[str, list]) -> None:\n    lora_counter = 0\n    for value in widget_values:\n        if isinstance(value, dict):\n            if not value:\n                continue\n            if \"type\" in value:\n                name = value.get(\"type\")\n                if name and name not in link_inputs:\n                    out[name] = value\n            elif \"lora\" in value:\n                lora_counter += 1\n                name = f\"lora_{lora_counter}\"\n                if name in link_inputs:\n                    continue\n                clean = {k: v for k, v in value.items() if k != \"strengthTwo\" or v is not None}\n                out[name] = clean\n        elif isinstance(value, str) and value == \"\":\n            # Frontend's \"Add Lora\" button serializes as an empty string trailer.\n            out.setdefault(\"➕ Add Lora\", value)\n\n\ndef _collect_default_inputs(\n    schema: dict | None,\n    widget_inputs: dict[str, Any],\n    primitive_inputs: dict[str, Any],\n    link_inputs: dict[str, list],\n) -> dict[str, Any]:\n    if not schema:\n        return {}\n    input_def = _schema_input_def(schema)\n    defaults: dict[str, Any] = {}\n    for section in (\"required\", \"optional\"):\n        section_def = input_def.get(section) or {}\n        if not isinstance(section_def, dict):\n            continue\n        for input_name, input_spec in section_def.items():\n            if input_name in widget_inputs or input_name in primitive_inputs or input_name in link_inputs:\n                continue\n            default = _extract_default(input_spec)\n            if default is not _MISSING:\n                defaults[input_name] = _wrap_widget_value(default)\n    return defaults\n\n\n_MISSING = object()\n\n\ndef _extract_default(input_spec: Any) -> Any:\n    if not isinstance(input_spec, (list, tuple)) or not input_spec:\n        return _MISSING\n    input_type = input_spec[0]\n    options = input_spec[1] if len(input_spec) >= 2 and isinstance(input_spec[1], dict) else {}\n    if \"default\" in options:\n        return options[\"default\"]\n    if isinstance(input_type, list) and input_type:\n        return input_type[0]\n    if input_type == \"COMBO\":\n        opts = options.get(\"options\")\n        if isinstance(opts, list) and opts:\n            return opts[0]\n    return _MISSING\n\n\ndef _normalize_combo_values(schema: dict | None, inputs: dict[str, Any]) -> None:\n    if not schema:\n        return\n    input_def = _schema_input_def(schema)\n    for section in (\"required\", \"optional\"):\n        section_def = input_def.get(section) or {}\n        if not isinstance(section_def, dict):\n            continue\n        for input_name, input_spec in section_def.items():\n            if input_name not in inputs:\n                continue\n            value = inputs[input_name]\n            if not isinstance(value, str):\n                continue\n            if not isinstance(input_spec, (list, tuple)) or not input_spec:\n                continue\n            allowed = input_spec[0]\n            if not isinstance(allowed, (list, tuple)):\n                continue\n            if value in allowed:\n                continue\n            lower_value = value.lower()\n            for option in allowed:\n                if isinstance(option, str) and option.lower() == lower_value:\n                    inputs[input_name] = option\n                    break\n"
  },
  {
    "path": "comfy_cli/workspace_manager.py",
    "content": "import concurrent.futures\nimport os\nfrom dataclasses import dataclass, field\nfrom datetime import datetime\nfrom enum import Enum\nfrom pathlib import Path\n\nimport git\nimport typer\nimport yaml\nfrom rich import print\n\nfrom comfy_cli import constants, logging, utils\nfrom comfy_cli.config_manager import ConfigManager\nfrom comfy_cli.utils import singleton\n\n\n@dataclass\nclass ModelPath:\n    path: str\n\n\n@dataclass\nclass Model:\n    name: str | None = None\n    url: str | None = None\n    paths: list[ModelPath] = field(default_factory=list)\n    hash: str | None = None\n    type: str | None = None\n\n\n@dataclass\nclass Basics:\n    name: str | None = None\n    updated_at: datetime = None\n\n\n@dataclass\nclass CustomNode:\n    # Todo: Add custom node fields for comfy-lock.yaml\n    pass\n\n\n@dataclass\nclass ComfyLockYAMLStruct:\n    basics: Basics\n    models: list[Model] = field(default_factory=list)\n    custom_nodes: list[CustomNode] = field(default_factory=list)\n\n\ndef _paths_match(path_a: str, path_b: str) -> bool:\n    try:\n        return os.path.samefile(path_a, path_b)\n    except (FileNotFoundError, OSError):\n        return os.path.realpath(path_a) == os.path.realpath(path_b)\n\n\ndef _has_comfyui_markers(path: str) -> bool:\n    \"\"\"Check for ComfyUI-specific files/directories when git metadata isn't available.\"\"\"\n    markers = [\"main.py\", \"comfy\", \"nodes.py\", \"comfy_extras\", \"comfy_api\"]\n    return sum(os.path.exists(os.path.join(path, m)) for m in markers) >= 4\n\n\ndef _find_comfyui_root(path: str) -> str | None:\n    \"\"\"Walk up from *path* looking for a directory with ComfyUI markers.\"\"\"\n    cur = os.path.abspath(path)\n    if not os.path.isdir(cur):\n        cur = os.path.dirname(cur)\n    while True:\n        if _has_comfyui_markers(cur):\n            return cur\n        parent = os.path.dirname(cur)\n        if parent == cur:\n            return None\n        cur = parent\n\n\ndef check_comfy_repo(path) -> tuple[bool, str | None]:\n    \"\"\"Check whether *path* is (or is inside) a ComfyUI installation.\n\n    Returns ``(True, resolved_path)`` on success, ``(False, None)`` otherwise.\n    Git remote-URL matching is tried first; if that fails (no ``.git``, fork,\n    mirror, zip download, portable build) a file-based marker check is used as\n    a fallback.\n    \"\"\"\n    if not os.path.exists(path):\n        return False, None\n    try:\n        repo = git.Repo(path, search_parent_directories=True)\n        path_is_comfy_repo = any(remote.url in constants.COMFY_ORIGIN_URL_CHOICES for remote in repo.remotes)\n\n        # If it's within the custom node repo, lookup from the parent directory.\n        if not path_is_comfy_repo and \"custom_nodes\" in path:\n            parts = path.split(os.sep)\n            try:\n                index = parts.index(\"custom_nodes\")\n                parent = os.sep.join(parts[:index])\n\n                repo = git.Repo(parent, search_parent_directories=True)\n                path_is_comfy_repo = any(remote.url in constants.COMFY_ORIGIN_URL_CHOICES for remote in repo.remotes)\n            except ValueError:\n                pass\n\n        if path_is_comfy_repo:\n            return True, str(repo.working_dir)\n    # Not in a git repo at all\n    # pylint: disable=E1101  # no-member\n    except git.exc.InvalidGitRepositoryError:\n        pass\n\n    # Fallback: file-based detection for non-git installations (zip downloads,\n    # portable builds, forks with non-standard remotes, etc.)\n    marker_root = _find_comfyui_root(path)\n    if marker_root is not None:\n        return True, marker_root\n\n    return False, None\n\n\n# Generate and update this following method using chatGPT\n# def load_yaml(file_path: str) -> ComfyLockYAMLStruct:\n#     with open(file_path, \"r\", encoding=\"utf-8\") as file:\n#         data = yaml.safe_load(file)\n#         basics = Basics(\n#             name=data.get(\"basics\", {}).get(\"name\"),\n#             updated_at=(\n#                 datetime.fromisoformat(data.get(\"basics\", {}).get(\"updated_at\"))\n#                 if data.get(\"basics\", {}).get(\"updated_at\")\n#                 else None\n#             ),\n#         )\n#         models = [\n#             Model(\n#                 name=m.get(\"model\"),\n#                 url=m.get(\"url\"),\n#                 paths=[ModelPath(path=p.get(\"path\")) for p in m.get(\"paths\", [])],\n#                 hash=m.get(\"hash\"),\n#                 type=m.get(\"type\"),\n#             )\n#             for m in data.get(\"models\", [])\n#         ]\n#         custom_nodes = []\n\n\n# Generate and update this following method using chatGPT\ndef save_yaml(file_path: str, metadata: ComfyLockYAMLStruct):\n    data = {\n        \"basics\": {\n            \"name\": metadata.basics.name,\n            \"updated_at\": metadata.basics.updated_at.isoformat(),\n        },\n        \"models\": [\n            {\n                \"model\": m.name,\n                \"url\": m.url,\n                \"paths\": [{\"path\": p.path} for p in m.paths],\n                \"hash\": m.hash,\n                \"type\": m.type,\n            }\n            for m in metadata.models\n        ],\n        \"custom_nodes\": [],\n    }\n    with open(file_path, \"w\", encoding=\"utf-8\") as file:\n        yaml.safe_dump(data, file, default_flow_style=False, allow_unicode=True)\n\n\n# Function to check if the file is config.json\ndef check_file_is_model(path):\n    if path.name.endswith(constants.SUPPORTED_PT_EXTENSIONS):\n        return str(path)\n\n\nclass WorkspaceType(Enum):\n    CURRENT_DIR = \"current_dir\"\n    DEFAULT = \"default\"\n    SPECIFIED = \"specified\"\n    RECENT = \"recent\"\n\n\n@singleton\nclass WorkspaceManager:\n    def __init__(\n        self,\n    ):\n        self.config_manager = ConfigManager()\n        self.metadata = ComfyLockYAMLStruct(basics=Basics(), models=[])\n        self.specified_workspace = None\n        self.use_here = None\n        self.use_recent = None\n        self.workspace_path = None\n        self.workspace_type = None\n        self.skip_prompting = None\n\n    def setup_workspace_manager(\n        self,\n        specified_workspace: str | None = None,\n        use_here: bool | None = None,\n        use_recent: bool | None = None,\n        skip_prompting: bool | None = None,\n    ):\n        self.specified_workspace = specified_workspace\n        self.use_here = use_here\n        self.use_recent = use_recent\n        self.workspace_path, self.workspace_type = self.get_workspace_path()\n        self.skip_prompting = skip_prompting\n\n    def set_recent_workspace(self, path: str):\n        \"\"\"\n        Sets the most recent workspace path in the configuration.\n        \"\"\"\n        self.config_manager.set(constants.CONFIG_KEY_RECENT_WORKSPACE, os.path.abspath(path))\n\n    def set_default_workspace(self, path: str):\n        \"\"\"\n        Sets the default workspace path in the configuration.\n        \"\"\"\n        self.config_manager.set(constants.CONFIG_KEY_DEFAULT_WORKSPACE, os.path.abspath(path))\n\n    def set_default_launch_extras(self, extras: str):\n        \"\"\"\n        Sets the default workspace path in the configuration.\n        \"\"\"\n        self.config_manager.set(constants.CONFIG_KEY_DEFAULT_LAUNCH_EXTRAS, extras.strip())\n\n    def __get_specified_workspace(self) -> str | None:\n        if self.specified_workspace is None:\n            return None\n\n        return os.path.abspath(os.path.expanduser(self.specified_workspace))\n\n    def get_workspace_path(self) -> tuple[str, WorkspaceType]:\n        \"\"\"\n        Retrieves a workspace path based on user input and defaults. This function does not validate the existence of a validate ComfyUI workspace.\n        1. Specified Workspace (--workspace)\n        2. Most Recent (if --recent is True)\n        3. Current Directory (if --here is True)\n        4. Current Directory (if current dir is ComfyUI repo and --no-here is not True;\n           returns DEFAULT if cwd matches the configured default workspace)\n        5. Default Workspace (if a default workspace has been set using `comfy set-default`)\n        6. Most Recent Workspace (if --no-recent is not True)\n        7. Fallback Default Workspace ('~/comfy' for linux or ~/Documents/comfy for windows/macos)\n\n        \"\"\"\n        # Check for explicitly specified workspace first\n        specified_workspace = self.__get_specified_workspace()\n        if specified_workspace:\n            return specified_workspace, WorkspaceType.SPECIFIED\n\n        # Check for recent workspace if requested\n        if self.use_recent:\n            recent_workspace = self.config_manager.get(constants.CONFIG_KEY_RECENT_WORKSPACE)\n            if recent_workspace:\n                return recent_workspace, WorkspaceType.RECENT\n            else:\n                print(\n                    \"[bold red]warn: No recent workspace has been set.[/bold red]\"\n                )  # If a path has been explicitly specified, cancel the command for safety.\n                raise typer.Exit(code=1)\n\n        # Check for current workspace if requested\n        if self.use_here is True:\n            current_directory = os.getcwd()\n            found_comfy_repo, comfy_path = check_comfy_repo(current_directory)\n            if found_comfy_repo:\n                return comfy_path, WorkspaceType.CURRENT_DIR\n            else:\n                return (\n                    os.path.join(current_directory, \"ComfyUI\"),\n                    WorkspaceType.CURRENT_DIR,\n                )\n\n        # Check the current directory for a ComfyUI\n        if self.use_here is None:\n            current_directory = os.getcwd()\n            found_comfy_repo, comfy_path = check_comfy_repo(os.path.join(current_directory))\n            # If it's in a sub dir of the ComfyUI repo, get the repo working dir\n            if found_comfy_repo:\n                default_workspace = self.config_manager.get(constants.CONFIG_KEY_DEFAULT_WORKSPACE)\n                if default_workspace and _paths_match(comfy_path, default_workspace):\n                    return comfy_path, WorkspaceType.DEFAULT\n                return comfy_path, WorkspaceType.CURRENT_DIR\n\n        # Check for user-set default workspace\n        default_workspace = self.config_manager.get(constants.CONFIG_KEY_DEFAULT_WORKSPACE)\n\n        if default_workspace and check_comfy_repo(default_workspace)[0]:\n            return default_workspace, WorkspaceType.DEFAULT\n\n        # Fallback to the most recent workspace if it exists\n        if self.use_recent is None:\n            recent_workspace = self.config_manager.get(constants.CONFIG_KEY_RECENT_WORKSPACE)\n            if recent_workspace:\n                if check_comfy_repo(recent_workspace)[0]:\n                    return recent_workspace, WorkspaceType.RECENT\n                else:\n                    self.config_manager.set(constants.CONFIG_KEY_RECENT_WORKSPACE, \"\")\n                    print(\n                        f\"[bold red]warn: Recent workspace '{recent_workspace}' is not a valid ComfyUI path. Reset.[/bold red]\"\n                    )\n\n        # Check for comfy-cli default workspace\n        default_workspace = utils.get_not_user_set_default_workspace()\n        return default_workspace, WorkspaceType.DEFAULT\n\n    def scan_dir(self):\n        if not self.workspace_path:\n            return []\n\n        logging.info(f\"Scanning directory: {self.workspace_path}\")\n        model_files = []\n        for root, _dirs, files in os.walk(self.workspace_path):\n            for file in files:\n                if file.endswith(constants.SUPPORTED_PT_EXTENSIONS):\n                    model_files.append(os.path.join(root, file))\n        return model_files\n\n    def scan_dir_concur(self):\n        base_path = Path(\".\")\n        model_files = []\n\n        # Use ThreadPoolExecutor to manage concurrency\n        with concurrent.futures.ThreadPoolExecutor() as executor:\n            futures = [executor.submit(check_file_is_model, p) for p in base_path.rglob(\"*\")]\n            for future in concurrent.futures.as_completed(futures):\n                if future.result():\n                    model_files.append(future.result())\n\n        return model_files\n\n    def load_metadata(self):\n        file_path = os.path.join(self.workspace_path, constants.COMFY_LOCK_YAML_FILE)\n        if os.path.exists(file_path):\n            with open(file_path, encoding=\"utf-8\") as file:\n                return yaml.safe_load(file)\n        else:\n            return {}\n\n    def save_metadata(self):\n        file_path = os.path.join(self.workspace_path, constants.COMFY_LOCK_YAML_FILE)\n        save_yaml(file_path, self.metadata)\n\n    def fill_print_table(self):\n        # Lazy import to avoid circular dependency\n        from comfy_cli.command.custom_nodes.cm_cli_util import resolve_manager_gui_mode\n\n        config_manager = ConfigManager()\n        mode = resolve_manager_gui_mode(not_installed_value=\"not-installed\")\n\n        status_map = {\n            \"disable\": \"[bold red]Disabled[/bold red]\",\n            \"enable-gui\": \"[bold green]GUI Enabled[/bold green]\",\n            \"disable-gui\": \"[bold yellow]GUI Disabled[/bold yellow]\",\n            \"enable-legacy-gui\": \"[bold cyan]Legacy GUI[/bold cyan]\",\n            \"not-installed\": \"[dim]Not Installed[/dim]\",\n        }\n        manager_status = status_map.get(mode, \"[bold green]GUI Enabled[/bold green]\")\n\n        uv_compile_value = config_manager.get(constants.CONFIG_KEY_UV_COMPILE_DEFAULT)\n        if uv_compile_value is not None and str(uv_compile_value).lower() == \"true\":\n            uv_compile_status = \"[bold green]Enabled[/bold green]\"\n        else:\n            uv_compile_status = \"[dim]Disabled[/dim]\"\n\n        return [\n            (\"Current selected workspace\", f\"[bold green]→ {self.workspace_path}[/bold green]\"),\n            (\"Manager\", manager_status),\n            (\"UV Compile Default\", uv_compile_status),\n        ]\n"
  },
  {
    "path": "conda.listing.txt",
    "content": "Loading channels: ...working... done\npython 3.8.11 hbdb9e5c_5\n------------------------\nfile name   : python-3.8.11-hbdb9e5c_5.conda\nname        : python\nversion     : 3.8.11\nbuild       : hbdb9e5c_5\nbuild number: 5\nsize        : 10.1 MB\nlicense     : Python-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.11-hbdb9e5c_5.conda\nmd5         : 2436f07f2fe23409ff5d29057225820c\ntimestamp   : 2021-08-16 10:07:56 UTC\ndependencies: \n  - libcxx >=12.0.0\n  - libffi >=3.3,<4.0a0\n  - ncurses >=6.2,<7.0a0\n  - openssl >=1.1.1j,<1.1.2a\n  - readline >=8.1,<9.0a0\n  - sqlite >=3.35.4,<4.0a0\n  - tk >=8.6.10,<8.7.0a0\n  - xz >=5.2.5,<6.0a0\n  - zlib >=1.2.11,<1.3.0a0\n  - pip\n\n\npython 3.8.13 hbdb9e5c_0\n------------------------\nfile name   : python-3.8.13-hbdb9e5c_0.conda\nname        : python\nversion     : 3.8.13\nbuild       : hbdb9e5c_0\nbuild number: 0\nsize        : 10.6 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.13-hbdb9e5c_0.conda\nmd5         : 393886f7e6f2096ccf8bb651d38a6022\ntimestamp   : 2022-03-28 11:17:08 UTC\ndependencies: \n  - libcxx >=12.0.0\n  - libffi >=3.3,<4.0a0\n  - ncurses >=6.3,<7.0a0\n  - openssl >=1.1.1n,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.38.0,<4.0a0\n  - tk >=8.6.11,<8.7.0a0\n  - xz >=5.2.5,<6.0a0\n  - zlib >=1.2.11,<1.3.0a0\n  - pip\n\n\npython 3.8.13 hbdb9e5c_1\n------------------------\nfile name   : python-3.8.13-hbdb9e5c_1.conda\nname        : python\nversion     : 3.8.13\nbuild       : hbdb9e5c_1\nbuild number: 1\nsize        : 10.6 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.13-hbdb9e5c_1.conda\nmd5         : 2f0dbf5c6c2fedbfc3775f92cb964b3f\ntimestamp   : 2022-10-19 22:56:01 UTC\ndependencies: \n  - libcxx >=12.0.0\n  - libffi >=3.3,<4.0a0\n  - ncurses >=6.3,<7.0a0\n  - openssl >=1.1.1q,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.39.3,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - xz >=5.2.6,<6.0a0\n  - zlib >=1.2.12,<1.3.0a0\n  - pip\n\n\npython 3.8.15 h266c4f5_0\n------------------------\nfile name   : python-3.8.15-h266c4f5_0.conda\nname        : python\nversion     : 3.8.15\nbuild       : h266c4f5_0\nbuild number: 0\nsize        : 12.5 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.15-h266c4f5_0.conda\nmd5         : a8a7637c6349662b86bd3805efdc1cb8\ntimestamp   : 2022-11-10 19:18:34 UTC\ndependencies: \n  - libcxx >=14.0.6\n  - libffi >=3.3,<4.0a0\n  - ncurses >=6.3,<7.0a0\n  - openssl >=1.1.1s,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.39.3,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - xz >=5.2.6,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.8.15 hc0d8a6c_2\n------------------------\nfile name   : python-3.8.15-hc0d8a6c_2.conda\nname        : python\nversion     : 3.8.15\nbuild       : hc0d8a6c_2\nbuild number: 2\nsize        : 12.3 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.15-hc0d8a6c_2.conda\nmd5         : cdf4af92159db1d56e48478e03ff47ba\ntimestamp   : 2022-11-24 15:05:30 UTC\ndependencies: \n  - libcxx >=14.0.6\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.3,<7.0a0\n  - openssl >=1.1.1s,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.40.0,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - xz >=5.2.6,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.8.16 hb885b13_4\n------------------------\nfile name   : python-3.8.16-hb885b13_4.conda\nname        : python\nversion     : 3.8.16\nbuild       : hb885b13_4\nbuild number: 4\nsize        : 12.5 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.16-hb885b13_4.conda\nmd5         : c510a6cfe22dac91f424b553e203583a\ntimestamp   : 2023-06-12 17:55:35 UTC\ndependencies: \n  - libcxx >=14.0.6\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=3.0.8,<4.0a0\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - xz >=5.4.2,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.8.16 hc0d8a6c_2\n------------------------\nfile name   : python-3.8.16-hc0d8a6c_2.conda\nname        : python\nversion     : 3.8.16\nbuild       : hc0d8a6c_2\nbuild number: 2\nsize        : 12.6 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.16-hc0d8a6c_2.conda\nmd5         : 69452a039f7a8f8341550c05a4f7bf24\ntimestamp   : 2023-01-17 22:43:16 UTC\ndependencies: \n  - libcxx >=14.0.6\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.3,<7.0a0\n  - openssl >=1.1.1s,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.40.1,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - xz >=5.2.10,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.8.16 hc0d8a6c_3\n------------------------\nfile name   : python-3.8.16-hc0d8a6c_3.conda\nname        : python\nversion     : 3.8.16\nbuild       : hc0d8a6c_3\nbuild number: 3\nsize        : 12.6 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.16-hc0d8a6c_3.conda\nmd5         : 28dad6a0dbda41b86b592ccc0bcb4a70\ntimestamp   : 2023-03-02 03:22:19 UTC\ndependencies: \n  - libcxx >=14.0.6\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=1.1.1t,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.40.1,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - xz >=5.2.10,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.8.17 hb885b13_0\n------------------------\nfile name   : python-3.8.17-hb885b13_0.conda\nname        : python\nversion     : 3.8.17\nbuild       : hb885b13_0\nbuild number: 0\nsize        : 14.2 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.17-hb885b13_0.conda\nmd5         : 8fe4e692f621f341fb45b67b8c2d97a0\ntimestamp   : 2023-07-05 20:40:10 UTC\ndependencies: \n  - libcxx >=14.0.6\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=3.0.9,<4.0a0\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - xz >=5.4.2,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.8.17 hc0d8a6c_0\n------------------------\nfile name   : python-3.8.17-hc0d8a6c_0.conda\nname        : python\nversion     : 3.8.17\nbuild       : hc0d8a6c_0\nbuild number: 0\nsize        : 14.2 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.17-hc0d8a6c_0.conda\nmd5         : aabea42c02d4f3bf2cfe289bc84cecd7\ntimestamp   : 2023-07-05 20:48:59 UTC\ndependencies: \n  - libcxx >=14.0.6\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=1.1.1u,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - xz >=5.4.2,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.8.18 hb885b13_0\n------------------------\nfile name   : python-3.8.18-hb885b13_0.conda\nname        : python\nversion     : 3.8.18\nbuild       : hb885b13_0\nbuild number: 0\nsize        : 14.1 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.18-hb885b13_0.conda\nmd5         : 545c4eb85fb1283450428e3fa965c12e\ntimestamp   : 2023-09-11 13:20:43 UTC\ndependencies: \n  - libcxx >=14.0.6\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=3.0.10,<4.0a0\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - xz >=5.4.2,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.8.18 hc0d8a6c_0\n------------------------\nfile name   : python-3.8.18-hc0d8a6c_0.conda\nname        : python\nversion     : 3.8.18\nbuild       : hc0d8a6c_0\nbuild number: 0\nsize        : 14.1 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.18-hc0d8a6c_0.conda\nmd5         : d02a8fed1d0268879da78d359c35f2f6\ntimestamp   : 2023-09-11 13:29:13 UTC\ndependencies: \n  - libcxx >=14.0.6\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=1.1.1v,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - xz >=5.4.2,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.8.19 hb885b13_0\n------------------------\nfile name   : python-3.8.19-hb885b13_0.conda\nname        : python\nversion     : 3.8.19\nbuild       : hb885b13_0\nbuild number: 0\nsize        : 12.5 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.8.19-hb885b13_0.conda\nmd5         : 839957fef1b8aa661305f34467949a2e\ntimestamp   : 2024-03-20 20:31:29 UTC\ndependencies: \n  - libcxx >=14.0.6\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=3.0.13,<4.0a0\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - xz >=5.4.6,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.9.6 hc70090a_5\n-----------------------\nfile name   : python-3.9.6-hc70090a_5.conda\nname        : python\nversion     : 3.9.6\nbuild       : hc70090a_5\nbuild number: 5\nsize        : 10.0 MB\nlicense     : Python-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.6-hc70090a_5.conda\nmd5         : bdf8e5b921efe19f13ef6fac45a5564f\ntimestamp   : 2021-08-16 10:45:59 UTC\ndependencies: \n  - expat >=2.4.1,<3.0a0\n  - libcxx >=12.0.0\n  - libffi >=3.3,<4.0a0\n  - libiconv >=1.16,<2.0a0\n  - ncurses >=6.2,<7.0a0\n  - openssl >=1.1.1j,<1.1.2a\n  - readline >=8.1,<9.0a0\n  - sqlite >=3.35.4,<4.0a0\n  - tk >=8.6.10,<8.7.0a0\n  - tzdata\n  - xz >=5.2.5,<6.0a0\n  - zlib >=1.2.11,<1.3.0a0\n  - pip\n\n\npython 3.9.7 hc70090a_1\n-----------------------\nfile name   : python-3.9.7-hc70090a_1.conda\nname        : python\nversion     : 3.9.7\nbuild       : hc70090a_1\nbuild number: 1\nsize        : 9.6 MB\nlicense     : Python-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.7-hc70090a_1.conda\nmd5         : 1c300656fadc104a32f159071f3e3648\ntimestamp   : 2021-09-16 21:56:41 UTC\ndependencies: \n  - expat >=2.4.1,<3.0a0\n  - libcxx >=12.0.0\n  - libffi >=3.3,<4.0a0\n  - libiconv >=1.16,<2.0a0\n  - ncurses >=6.2,<7.0a0\n  - openssl >=1.1.1j,<1.1.2a\n  - readline >=8.1,<9.0a0\n  - sqlite >=3.36.0,<4.0a0\n  - tk >=8.6.10,<8.7.0a0\n  - tzdata\n  - xz >=5.2.5,<6.0a0\n  - zlib >=1.2.11,<1.3.0a0\n  - pip\n\n\npython 3.9.11 hbdb9e5c_1\n------------------------\nfile name   : python-3.9.11-hbdb9e5c_1.conda\nname        : python\nversion     : 3.9.11\nbuild       : hbdb9e5c_1\nbuild number: 1\nsize        : 10.2 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.11-hbdb9e5c_1.conda\nmd5         : 7602582b891b47e986c2367038eaa3dd\ntimestamp   : 2022-03-28 10:09:25 UTC\ndependencies: \n  - libcxx >=12.0.0\n  - libffi >=3.3,<4.0a0\n  - ncurses >=6.3,<7.0a0\n  - openssl >=1.1.1n,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.38.0,<4.0a0\n  - tk >=8.6.11,<8.7.0a0\n  - tzdata\n  - xz >=5.2.5,<6.0a0\n  - zlib >=1.2.11,<1.3.0a0\n  - pip\n\n\npython 3.9.11 hbdb9e5c_2\n------------------------\nfile name   : python-3.9.11-hbdb9e5c_2.conda\nname        : python\nversion     : 3.9.11\nbuild       : hbdb9e5c_2\nbuild number: 2\nsize        : 10.1 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.11-hbdb9e5c_2.conda\nmd5         : c4e41bae925a2eb489f0c8644bc8f2ee\ntimestamp   : 2022-03-29 19:07:49 UTC\ndependencies: \n  - libcxx >=12.0.0\n  - libffi >=3.3,<4.0a0\n  - ncurses >=6.3,<7.0a0\n  - openssl >=1.1.1n,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.38.0,<4.0a0\n  - tk >=8.6.11,<8.7.0a0\n  - tzdata\n  - xz >=5.2.5,<6.0a0\n  - zlib >=1.2.11,<1.3.0a0\n  - pip\n\n\npython 3.9.12 hbdb9e5c_0\n------------------------\nfile name   : python-3.9.12-hbdb9e5c_0.conda\nname        : python\nversion     : 3.9.12\nbuild       : hbdb9e5c_0\nbuild number: 0\nsize        : 10.2 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.12-hbdb9e5c_0.conda\nmd5         : 6d4e471648e21dd0dadd853bf54d8a28\ntimestamp   : 2022-04-05 06:55:50 UTC\ndependencies: \n  - libcxx >=12.0.0\n  - libffi >=3.3,<4.0a0\n  - ncurses >=6.3,<7.0a0\n  - openssl >=1.1.1n,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.38.2,<4.0a0\n  - tk >=8.6.11,<8.7.0a0\n  - tzdata\n  - xz >=5.2.5,<6.0a0\n  - zlib >=1.2.11,<1.3.0a0\n  - pip\n\n\npython 3.9.12 hbdb9e5c_1\n------------------------\nfile name   : python-3.9.12-hbdb9e5c_1.conda\nname        : python\nversion     : 3.9.12\nbuild       : hbdb9e5c_1\nbuild number: 1\nsize        : 10.2 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.12-hbdb9e5c_1.conda\nmd5         : 53fbd267a0fc103b65fc058892b55519\ntimestamp   : 2022-06-01 11:38:07 UTC\ndependencies: \n  - libcxx >=12.0.0\n  - libffi >=3.3,<4.0a0\n  - ncurses >=6.3,<7.0a0\n  - openssl >=1.1.1o,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.38.3,<4.0a0\n  - tk >=8.6.11,<8.7.0a0\n  - tzdata\n  - xz >=5.2.5,<6.0a0\n  - zlib >=1.2.12,<1.3.0a0\n  - pip\n\n\npython 3.9.13 hbdb9e5c_1\n------------------------\nfile name   : python-3.9.13-hbdb9e5c_1.conda\nname        : python\nversion     : 3.9.13\nbuild       : hbdb9e5c_1\nbuild number: 1\nsize        : 10.1 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.13-hbdb9e5c_1.conda\nmd5         : 007c2e16a887d4dc3ab85fb765a5005f\ntimestamp   : 2022-08-25 23:31:26 UTC\ndependencies: \n  - libcxx >=12.0.0\n  - libffi >=3.3,<4.0a0\n  - ncurses >=6.3,<7.0a0\n  - openssl >=1.1.1q,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.39.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.2.5,<6.0a0\n  - zlib >=1.2.12,<1.3.0a0\n  - pip\n\n\npython 3.9.13 hbdb9e5c_2\n------------------------\nfile name   : python-3.9.13-hbdb9e5c_2.conda\nname        : python\nversion     : 3.9.13\nbuild       : hbdb9e5c_2\nbuild number: 2\nsize        : 10.2 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.13-hbdb9e5c_2.conda\nmd5         : 185591d7dd14a96bb2e02654dacabba3\ntimestamp   : 2022-10-13 21:15:59 UTC\ndependencies: \n  - libcxx >=12.0.0\n  - libffi >=3.3,<4.0a0\n  - ncurses >=6.3,<7.0a0\n  - openssl >=1.1.1q,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.39.3,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.2.6,<6.0a0\n  - zlib >=1.2.12,<1.3.0a0\n  - pip\n\n\npython 3.9.15 hbdb9e5c_0\n------------------------\nfile name   : python-3.9.15-hbdb9e5c_0.conda\nname        : python\nversion     : 3.9.15\nbuild       : hbdb9e5c_0\nbuild number: 0\nsize        : 12.5 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.15-hbdb9e5c_0.conda\nmd5         : 552b857a1dcae1f85b375261336927a0\ntimestamp   : 2022-11-04 16:17:09 UTC\ndependencies: \n  - libcxx >=12.0.0\n  - libffi >=3.3,<4.0a0\n  - ncurses >=6.3,<7.0a0\n  - openssl >=1.1.1q,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.39.3,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.2.6,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.9.15 hc0d8a6c_2\n------------------------\nfile name   : python-3.9.15-hc0d8a6c_2.conda\nname        : python\nversion     : 3.9.15\nbuild       : hc0d8a6c_2\nbuild number: 2\nsize        : 12.5 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.15-hc0d8a6c_2.conda\nmd5         : e28533a8091cab7be3f75151ba71b335\ntimestamp   : 2022-11-24 14:32:05 UTC\ndependencies: \n  - libcxx >=14.0.6\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.3,<7.0a0\n  - openssl >=1.1.1s,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.40.0,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.2.6,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.9.16 hb885b13_3\n------------------------\nfile name   : python-3.9.16-hb885b13_3.conda\nname        : python\nversion     : 3.9.16\nbuild       : hb885b13_3\nbuild number: 3\nsize        : 12.6 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.16-hb885b13_3.conda\nmd5         : 0ceab7d1b1b5f3c1b65d0ffbf9f20d7e\ntimestamp   : 2023-05-16 19:31:39 UTC\ndependencies: \n  - libcxx >=14.0.6\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=3.0.8,<4.0a0\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.4.2,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.9.16 hc0d8a6c_0\n------------------------\nfile name   : python-3.9.16-hc0d8a6c_0.conda\nname        : python\nversion     : 3.9.16\nbuild       : hc0d8a6c_0\nbuild number: 0\nsize        : 12.6 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.16-hc0d8a6c_0.conda\nmd5         : 9dc504b7a7ad7766d0745a267b011682\ntimestamp   : 2023-01-11 16:05:53 UTC\ndependencies: \n  - libcxx >=14.0.6\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.3,<7.0a0\n  - openssl >=1.1.1s,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.40.1,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.2.8,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.9.16 hc0d8a6c_1\n------------------------\nfile name   : python-3.9.16-hc0d8a6c_1.conda\nname        : python\nversion     : 3.9.16\nbuild       : hc0d8a6c_1\nbuild number: 1\nsize        : 12.6 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.16-hc0d8a6c_1.conda\nmd5         : 4ad0c59b65109fcafa9fbabf38f8be25\ntimestamp   : 2023-03-01 18:23:04 UTC\ndependencies: \n  - libcxx >=14.0.6\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=1.1.1t,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.40.1,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.2.10,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.9.16 hc0d8a6c_2\n------------------------\nfile name   : python-3.9.16-hc0d8a6c_2.conda\nname        : python\nversion     : 3.9.16\nbuild       : hc0d8a6c_2\nbuild number: 2\nsize        : 12.6 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.16-hc0d8a6c_2.conda\nmd5         : f4f3fae25939c852bc4b3a4e52befddc\ntimestamp   : 2023-03-08 10:32:54 UTC\ndependencies: \n  - libcxx >=14.0.6\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=1.1.1t,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.40.1,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.2.10,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.9.17 hb885b13_0\n------------------------\nfile name   : python-3.9.17-hb885b13_0.conda\nname        : python\nversion     : 3.9.17\nbuild       : hb885b13_0\nbuild number: 0\nsize        : 12.5 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.17-hb885b13_0.conda\nmd5         : 6e170bd2d83a85df948e2caf2363f104\ntimestamp   : 2023-07-05 20:39:04 UTC\ndependencies: \n  - libcxx >=14.0.6\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=3.0.9,<4.0a0\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.4.2,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.9.17 hc0d8a6c_0\n------------------------\nfile name   : python-3.9.17-hc0d8a6c_0.conda\nname        : python\nversion     : 3.9.17\nbuild       : hc0d8a6c_0\nbuild number: 0\nsize        : 12.5 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.17-hc0d8a6c_0.conda\nmd5         : 8fcd5becc236d71202e70518eac0f55b\ntimestamp   : 2023-07-05 20:48:43 UTC\ndependencies: \n  - libcxx >=14.0.6\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=1.1.1u,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.4.2,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.9.18 hb885b13_0\n------------------------\nfile name   : python-3.9.18-hb885b13_0.conda\nname        : python\nversion     : 3.9.18\nbuild       : hb885b13_0\nbuild number: 0\nsize        : 11.6 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.18-hb885b13_0.conda\nmd5         : a846e3766c71915409871458683b5409\ntimestamp   : 2023-09-11 13:28:21 UTC\ndependencies: \n  - libcxx >=14.0.6\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=3.0.10,<4.0a0\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.4.2,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.9.18 hc0d8a6c_0\n------------------------\nfile name   : python-3.9.18-hc0d8a6c_0.conda\nname        : python\nversion     : 3.9.18\nbuild       : hc0d8a6c_0\nbuild number: 0\nsize        : 11.6 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.18-hc0d8a6c_0.conda\nmd5         : 673a7b485675b69c2cc3e7499951fe2a\ntimestamp   : 2023-09-11 13:19:38 UTC\ndependencies: \n  - libcxx >=14.0.6\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=1.1.1v,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.4.2,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.9.19 hb885b13_0\n------------------------\nfile name   : python-3.9.19-hb885b13_0.conda\nname        : python\nversion     : 3.9.19\nbuild       : hb885b13_0\nbuild number: 0\nsize        : 12.5 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.9.19-hb885b13_0.conda\nmd5         : 57411d22c3599a86c450fb9be9bee488\ntimestamp   : 2024-03-21 17:10:34 UTC\ndependencies: \n  - libcxx >=14.0.6\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=3.0.13,<4.0a0\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.4.6,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.10.0 hbdb9e5c_1\n------------------------\nfile name   : python-3.10.0-hbdb9e5c_1.tar.bz2\nname        : python\nversion     : 3.10.0\nbuild       : hbdb9e5c_1\nbuild number: 1\nsize        : 12.6 MB\nlicense     : Python-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.0-hbdb9e5c_1.tar.bz2\nmd5         : c344f41666ca0e45950b4484d855b6a5\ntimestamp   : 2021-10-07 09:17:07 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.3,<4.0a0\n  - ncurses >=6.2,<7.0a0\n  - openssl >=1.1.1j,<1.1.2a\n  - readline >=8.1,<9.0a0\n  - sqlite >=3.36.0,<4.0a0\n  - tk >=8.6.10,<8.7.0a0\n  - tzdata\n  - xz >=5.2.5,<6.0a0\n  - zlib >=1.2.11,<1.3.0a0\n  - pip\n\n\npython 3.10.0 hbdb9e5c_2\n------------------------\nfile name   : python-3.10.0-hbdb9e5c_2.tar.bz2\nname        : python\nversion     : 3.10.0\nbuild       : hbdb9e5c_2\nbuild number: 2\nsize        : 12.6 MB\nlicense     : Python-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.0-hbdb9e5c_2.tar.bz2\nmd5         : 96ba09e9aaf624a7246c38efb096fcab\ntimestamp   : 2021-11-09 13:59:27 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.3,<4.0a0\n  - ncurses >=6.3,<7.0a0\n  - openssl >=1.1.1j,<1.1.2a\n  - readline >=8.1,<9.0a0\n  - sqlite >=3.36.0,<4.0a0\n  - tk >=8.6.11,<8.7.0a0\n  - tzdata\n  - xz >=5.2.5,<6.0a0\n  - zlib >=1.2.11,<1.3.0a0\n  - pip\n\n\npython 3.10.0 hbdb9e5c_3\n------------------------\nfile name   : python-3.10.0-hbdb9e5c_3.tar.bz2\nname        : python\nversion     : 3.10.0\nbuild       : hbdb9e5c_3\nbuild number: 3\nsize        : 12.7 MB\nlicense     : Python-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.0-hbdb9e5c_3.tar.bz2\nmd5         : 546107c8688d70d72a64e2c6c4154014\ntimestamp   : 2021-11-10 19:32:03 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.3,<4.0a0\n  - ncurses >=6.3,<7.0a0\n  - openssl >=1.1.1j,<1.1.2a\n  - readline >=8.1,<9.0a0\n  - sqlite >=3.36.0,<4.0a0\n  - tk >=8.6.11,<8.7.0a0\n  - tzdata\n  - xz >=5.2.5,<6.0a0\n  - zlib >=1.2.11,<1.3.0a0\n  - pip\n\n\npython 3.10.0 hbdb9e5c_5\n------------------------\nfile name   : python-3.10.0-hbdb9e5c_5.tar.bz2\nname        : python\nversion     : 3.10.0\nbuild       : hbdb9e5c_5\nbuild number: 5\nsize        : 12.6 MB\nlicense     : Python-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.0-hbdb9e5c_5.tar.bz2\nmd5         : 7149a001b81f5d740645b1eee26d670d\ntimestamp   : 2022-03-03 09:58:25 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.3,<4.0a0\n  - ncurses >=6.3,<7.0a0\n  - openssl >=1.1.1m,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.37.2,<4.0a0\n  - tk >=8.6.11,<8.7.0a0\n  - tzdata\n  - xz >=5.2.5,<6.0a0\n  - zlib >=1.2.11,<1.3.0a0\n  - pip\n\n\npython 3.10.3 hbdb9e5c_5\n------------------------\nfile name   : python-3.10.3-hbdb9e5c_5.tar.bz2\nname        : python\nversion     : 3.10.3\nbuild       : hbdb9e5c_5\nbuild number: 5\nsize        : 13.2 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.3-hbdb9e5c_5.tar.bz2\nmd5         : 223945c60e1b472da9c1c3903a6d8ba8\ntimestamp   : 2022-03-28 09:29:24 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.3,<4.0a0\n  - ncurses >=6.3,<7.0a0\n  - openssl >=1.1.1n,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.38.0,<4.0a0\n  - tk >=8.6.11,<8.7.0a0\n  - tzdata\n  - xz >=5.2.5,<6.0a0\n  - zlib >=1.2.11,<1.3.0a0\n  - pip\n\n\npython 3.10.4 hbdb9e5c_0\n------------------------\nfile name   : python-3.10.4-hbdb9e5c_0.tar.bz2\nname        : python\nversion     : 3.10.4\nbuild       : hbdb9e5c_0\nbuild number: 0\nsize        : 13.1 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.4-hbdb9e5c_0.tar.bz2\nmd5         : f4aae673df5b9a783bffae7ef1110657\ntimestamp   : 2022-03-31 08:41:25 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.3,<4.0a0\n  - ncurses >=6.3,<7.0a0\n  - openssl >=1.1.1n,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.38.0,<4.0a0\n  - tk >=8.6.11,<8.7.0a0\n  - tzdata\n  - xz >=5.2.5,<6.0a0\n  - zlib >=1.2.11,<1.3.0a0\n  - pip\n\n\npython 3.10.6 hbdb9e5c_0\n------------------------\nfile name   : python-3.10.6-hbdb9e5c_0.conda\nname        : python\nversion     : 3.10.6\nbuild       : hbdb9e5c_0\nbuild number: 0\nsize        : 10.6 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.6-hbdb9e5c_0.conda\nmd5         : 88a21cb05becd5c8e9c400c843bf1e28\ntimestamp   : 2022-10-07 20:22:27 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.3,<4.0a0\n  - ncurses >=6.3,<7.0a0\n  - openssl >=1.1.1q,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.39.3,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.2.6,<6.0a0\n  - zlib >=1.2.12,<1.3.0a0\n  - pip\n\n\npython 3.10.6 hbdb9e5c_1\n------------------------\nfile name   : python-3.10.6-hbdb9e5c_1.conda\nname        : python\nversion     : 3.10.6\nbuild       : hbdb9e5c_1\nbuild number: 1\nsize        : 10.6 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.6-hbdb9e5c_1.conda\nmd5         : 82661371d0778f010384ed0a062cb3ae\ntimestamp   : 2022-10-24 16:08:02 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.3,<4.0a0\n  - ncurses >=6.3,<7.0a0\n  - openssl >=1.1.1q,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.39.3,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.2.6,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.10.8 hbdb9e5c_0\n------------------------\nfile name   : python-3.10.8-hbdb9e5c_0.conda\nname        : python\nversion     : 3.10.8\nbuild       : hbdb9e5c_0\nbuild number: 0\nsize        : 13.0 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.8-hbdb9e5c_0.conda\nmd5         : 545b607406621ce61491dde188a2ac24\ntimestamp   : 2022-11-04 13:49:28 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.3,<4.0a0\n  - ncurses >=6.3,<7.0a0\n  - openssl >=1.1.1q,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.39.3,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.2.6,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.10.8 hc0d8a6c_1\n------------------------\nfile name   : python-3.10.8-hc0d8a6c_1.conda\nname        : python\nversion     : 3.10.8\nbuild       : hc0d8a6c_1\nbuild number: 1\nsize        : 12.9 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.8-hc0d8a6c_1.conda\nmd5         : 6b0a8c1832a2b63c7bfb0f6de5967601\ntimestamp   : 2022-11-24 14:11:54 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.3,<7.0a0\n  - openssl >=1.1.1s,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.40.0,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.2.6,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.10.9 hc0d8a6c_0\n------------------------\nfile name   : python-3.10.9-hc0d8a6c_0.conda\nname        : python\nversion     : 3.10.9\nbuild       : hc0d8a6c_0\nbuild number: 0\nsize        : 12.0 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.9-hc0d8a6c_0.conda\nmd5         : f87a2e1f7595344e3d8e0842f0016831\ntimestamp   : 2023-01-11 15:21:53 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.3,<7.0a0\n  - openssl >=1.1.1s,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.40.1,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.2.8,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.10.9 hc0d8a6c_1\n------------------------\nfile name   : python-3.10.9-hc0d8a6c_1.conda\nname        : python\nversion     : 3.10.9\nbuild       : hc0d8a6c_1\nbuild number: 1\nsize        : 12.9 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.9-hc0d8a6c_1.conda\nmd5         : b49223285e681289fe846def063b8c61\ntimestamp   : 2023-03-01 18:24:33 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=1.1.1t,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.40.1,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.2.10,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.10.9 hc0d8a6c_2\n------------------------\nfile name   : python-3.10.9-hc0d8a6c_2.conda\nname        : python\nversion     : 3.10.9\nbuild       : hc0d8a6c_2\nbuild number: 2\nsize        : 13.0 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.9-hc0d8a6c_2.conda\nmd5         : ede0e4fc40d6eefe7ae13e82831e1578\ntimestamp   : 2023-03-08 10:48:06 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=1.1.1t,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.40.1,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.2.10,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.10.10 hc0d8a6c_2\n-------------------------\nfile name   : python-3.10.10-hc0d8a6c_2.conda\nname        : python\nversion     : 3.10.10\nbuild       : hc0d8a6c_2\nbuild number: 2\nsize        : 13.0 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.10-hc0d8a6c_2.conda\nmd5         : bd5ef8b4531bcd58ac2c00393a7d8c3f\ntimestamp   : 2023-03-21 18:44:40 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=1.1.1t,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.1,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.2.10,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.10.11 hb885b13_3\n-------------------------\nfile name   : python-3.10.11-hb885b13_3.conda\nname        : python\nversion     : 3.10.11\nbuild       : hb885b13_3\nbuild number: 3\nsize        : 13.0 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.11-hb885b13_3.conda\nmd5         : 9cfd49419eb60ea5fca2bc416708d152\ntimestamp   : 2023-05-17 19:34:14 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=3.0.8,<4.0a0\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.4.2,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.10.11 hc0d8a6c_2\n-------------------------\nfile name   : python-3.10.11-hc0d8a6c_2.conda\nname        : python\nversion     : 3.10.11\nbuild       : hc0d8a6c_2\nbuild number: 2\nsize        : 13.0 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.11-hc0d8a6c_2.conda\nmd5         : 658853b9ef9b3961212f913f69d93a51\ntimestamp   : 2023-04-20 19:02:14 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=1.1.1t,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.2.10,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.10.12 hb885b13_0\n-------------------------\nfile name   : python-3.10.12-hb885b13_0.conda\nname        : python\nversion     : 3.10.12\nbuild       : hb885b13_0\nbuild number: 0\nsize        : 13.0 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.12-hb885b13_0.conda\nmd5         : e48fe886a96893db3aab407ebb7e1878\ntimestamp   : 2023-07-05 20:05:59 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=3.0.9,<4.0a0\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.4.2,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.10.12 hc0d8a6c_0\n-------------------------\nfile name   : python-3.10.12-hc0d8a6c_0.conda\nname        : python\nversion     : 3.10.12\nbuild       : hc0d8a6c_0\nbuild number: 0\nsize        : 13.0 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.12-hc0d8a6c_0.conda\nmd5         : 5c51d1ea23c42b63ad5181e4d0d290d6\ntimestamp   : 2023-07-05 19:56:28 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=1.1.1u,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.4.2,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.10.13 hb885b13_0\n-------------------------\nfile name   : python-3.10.13-hb885b13_0.conda\nname        : python\nversion     : 3.10.13\nbuild       : hb885b13_0\nbuild number: 0\nsize        : 13.0 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.13-hb885b13_0.conda\nmd5         : 27eae236674f98a3bbb86231e68d0c83\ntimestamp   : 2023-09-11 13:19:30 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=3.0.10,<4.0a0\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.4.2,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.10.13 hc0d8a6c_0\n-------------------------\nfile name   : python-3.10.13-hc0d8a6c_0.conda\nname        : python\nversion     : 3.10.13\nbuild       : hc0d8a6c_0\nbuild number: 0\nsize        : 13.0 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.13-hc0d8a6c_0.conda\nmd5         : 19df08f8f498cffb70e567ab3e80a07a\ntimestamp   : 2023-09-11 13:28:12 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=1.1.1v,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.4.2,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.10.14 hb885b13_0\n-------------------------\nfile name   : python-3.10.14-hb885b13_0.conda\nname        : python\nversion     : 3.10.14\nbuild       : hb885b13_0\nbuild number: 0\nsize        : 13.0 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.10.14-hb885b13_0.conda\nmd5         : 0ad0a3a0082c794a6e34c93153baee3c\ntimestamp   : 2024-03-21 16:25:06 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=3.0.13,<4.0a0\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.4.6,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.11.0 hc0d8a6c_2\n------------------------\nfile name   : python-3.11.0-hc0d8a6c_2.conda\nname        : python\nversion     : 3.11.0\nbuild       : hc0d8a6c_2\nbuild number: 2\nsize        : 15.3 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.11.0-hc0d8a6c_2.conda\nmd5         : 1a7019acdc1637c47a0b2d94fd8ca044\ntimestamp   : 2023-01-16 17:19:20 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.3,<7.0a0\n  - openssl >=1.1.1s,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.40.1,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.2.8,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.11.0 hc0d8a6c_3\n------------------------\nfile name   : python-3.11.0-hc0d8a6c_3.conda\nname        : python\nversion     : 3.11.0\nbuild       : hc0d8a6c_3\nbuild number: 3\nsize        : 15.3 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.11.0-hc0d8a6c_3.conda\nmd5         : 6b08c690468a632dcfd0ef55e1b26ab0\ntimestamp   : 2023-03-01 18:41:04 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=1.1.1t,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.40.1,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.2.10,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.11.2 hc0d8a6c_0\n------------------------\nfile name   : python-3.11.2-hc0d8a6c_0.conda\nname        : python\nversion     : 3.11.2\nbuild       : hc0d8a6c_0\nbuild number: 0\nsize        : 15.3 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.11.2-hc0d8a6c_0.conda\nmd5         : 2436b0e3116ed03300c05217a4bf1fa6\ntimestamp   : 2023-03-27 23:45:26 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=1.1.1t,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.1,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.2.10,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.11.3 hb885b13_1\n------------------------\nfile name   : python-3.11.3-hb885b13_1.conda\nname        : python\nversion     : 3.11.3\nbuild       : hb885b13_1\nbuild number: 1\nsize        : 15.3 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.11.3-hb885b13_1.conda\nmd5         : 23c34a0cd50510828470216735b16180\ntimestamp   : 2023-05-15 23:09:06 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=3.0.8,<4.0a0\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.4.2,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.11.3 hc0d8a6c_0\n------------------------\nfile name   : python-3.11.3-hc0d8a6c_0.conda\nname        : python\nversion     : 3.11.3\nbuild       : hc0d8a6c_0\nbuild number: 0\nsize        : 15.3 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.11.3-hc0d8a6c_0.conda\nmd5         : 2fde54a2c622f9574e2fb47d20e92502\ntimestamp   : 2023-04-19 23:56:57 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=1.1.1t,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.2.10,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.11.4 hb885b13_0\n------------------------\nfile name   : python-3.11.4-hb885b13_0.conda\nname        : python\nversion     : 3.11.4\nbuild       : hb885b13_0\nbuild number: 0\nsize        : 15.3 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.11.4-hb885b13_0.conda\nmd5         : 3d614a1117ed0a2dc79776b2dc0b98e5\ntimestamp   : 2023-07-05 13:47:27 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=3.0.9,<4.0a0\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.4.2,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.11.4 hc0d8a6c_0\n------------------------\nfile name   : python-3.11.4-hc0d8a6c_0.conda\nname        : python\nversion     : 3.11.4\nbuild       : hc0d8a6c_0\nbuild number: 0\nsize        : 15.3 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.11.4-hc0d8a6c_0.conda\nmd5         : 2088239ff316e6d47923394f318afbe2\ntimestamp   : 2023-07-05 14:01:09 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=1.1.1u,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.4.2,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.11.5 hb885b13_0\n------------------------\nfile name   : python-3.11.5-hb885b13_0.conda\nname        : python\nversion     : 3.11.5\nbuild       : hb885b13_0\nbuild number: 0\nsize        : 15.4 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.11.5-hb885b13_0.conda\nmd5         : 6f528bdf159139704ab578df329dee70\ntimestamp   : 2023-09-11 13:37:21 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=3.0.10,<4.0a0\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.4.2,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.11.5 hc0d8a6c_0\n------------------------\nfile name   : python-3.11.5-hc0d8a6c_0.conda\nname        : python\nversion     : 3.11.5\nbuild       : hc0d8a6c_0\nbuild number: 0\nsize        : 15.4 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.11.5-hc0d8a6c_0.conda\nmd5         : 31403c947341ac90236eb4fe77681875\ntimestamp   : 2023-09-11 13:24:13 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=1.1.1v,<1.1.2a\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.4.2,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.11.7 hb885b13_0\n------------------------\nfile name   : python-3.11.7-hb885b13_0.conda\nname        : python\nversion     : 3.11.7\nbuild       : hb885b13_0\nbuild number: 0\nsize        : 15.4 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.11.7-hb885b13_0.conda\nmd5         : adc1a4b7f0736001e3cedf4597b24c0d\ntimestamp   : 2023-12-15 18:15:44 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=3.0.12,<4.0a0\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.4.5,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.11.8 hb885b13_0\n------------------------\nfile name   : python-3.11.8-hb885b13_0.conda\nname        : python\nversion     : 3.11.8\nbuild       : hb885b13_0\nbuild number: 0\nsize        : 15.5 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.11.8-hb885b13_0.conda\nmd5         : dd7e4ebe9abe2f6d7901fb861ef90ca5\ntimestamp   : 2024-02-26 21:43:06 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=3.0.13,<4.0a0\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.4.5,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.11.9 hb885b13_0\n------------------------\nfile name   : python-3.11.9-hb885b13_0.conda\nname        : python\nversion     : 3.11.9\nbuild       : hb885b13_0\nbuild number: 0\nsize        : 15.5 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.11.9-hb885b13_0.conda\nmd5         : c0fdd4a7e5a6af3681840d6b650cef87\ntimestamp   : 2024-04-19 16:52:24 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - libffi >=3.4,<3.5\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=3.0.13,<4.0a0\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.4.6,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.12.0 h99e199e_0\n------------------------\nfile name   : python-3.12.0-h99e199e_0.conda\nname        : python\nversion     : 3.12.0\nbuild       : h99e199e_0\nbuild number: 0\nsize        : 14.0 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.12.0-h99e199e_0.conda\nmd5         : b5d6ef7d1b8ed9ff778f8d4c69afd723\ntimestamp   : 2023-10-02 17:28:30 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - expat >=2.5.0,<3.0a0\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=3.0.11,<4.0a0\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.4.2,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.12.1 h99e199e_0\n------------------------\nfile name   : python-3.12.1-h99e199e_0.conda\nname        : python\nversion     : 3.12.1\nbuild       : h99e199e_0\nbuild number: 0\nsize        : 14.0 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.12.1-h99e199e_0.conda\nmd5         : 5ec5d4828c2cb7720fd1e80ed2e8548a\ntimestamp   : 2024-01-19 15:52:20 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - expat >=2.5.0,<3.0a0\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=3.0.12,<4.0a0\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.4.5,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.12.2 h99e199e_0\n------------------------\nfile name   : python-3.12.2-h99e199e_0.conda\nname        : python\nversion     : 3.12.2\nbuild       : h99e199e_0\nbuild number: 0\nsize        : 14.0 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.12.2-h99e199e_0.conda\nmd5         : ccdb24048e0e912e3a21eee26cdb7306\ntimestamp   : 2024-02-27 19:04:26 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - expat >=2.5.0,<3.0a0\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=3.0.13,<4.0a0\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.4.6,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\npython 3.12.3 h99e199e_0\n------------------------\nfile name   : python-3.12.3-h99e199e_0.conda\nname        : python\nversion     : 3.12.3\nbuild       : h99e199e_0\nbuild number: 0\nsize        : 14.0 MB\nlicense     : PSF-2.0\nsubdir      : osx-arm64\nurl         : https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.12.3-h99e199e_0.conda\nmd5         : 7495a2a48f01afd0866fb72d1a6697b7\ntimestamp   : 2024-04-19 16:54:45 UTC\ndependencies: \n  - bzip2 >=1.0.8,<2.0a0\n  - expat >=2.6.2,<3.0a0\n  - libffi >=3.4,<4.0a0\n  - ncurses >=6.4,<7.0a0\n  - openssl >=3.0.13,<4.0a0\n  - readline >=8.1.2,<9.0a0\n  - sqlite >=3.41.2,<4.0a0\n  - tk >=8.6.12,<8.7.0a0\n  - tzdata\n  - xz >=5.4.6,<6.0a0\n  - zlib >=1.2.13,<1.3.0a0\n  - pip\n\n\n"
  },
  {
    "path": "docs/DESIGN-uv-compile.md",
    "content": "# DESIGN: Unified Dependency Resolution (--uv-compile) Implementation\n\n## Architecture Decision: Pass-Through\n\ncm_cli already fully implements `--uv-compile` and `UnifiedDepResolver`, so\ncomfy-cli adopts a **pass-through** approach.\n\n**Rationale:**\n- Avoids duplicating logic already in cm_cli\n- Maintains separation of concerns with comfy-cli's `DependencyCompiler` (`--fast-deps`)\n- No comfy-cli changes needed when cm_cli updates its resolver\n\n**Alternative (rejected):** Import `UnifiedDepResolver` directly in comfy-cli\n— increases coupling between cm_cli and comfy-cli, adds maintenance burden.\n\n## Component Diagram\n\n```\nUser CLI\n  │\n  ├─ comfy node install --uv-compile\n  │     │\n  │     ▼\n  │  command.py:install()\n  │     │  1. mutual exclusivity check\n  │     │  2. _resolve_uv_compile() → effective value\n  │     │  3. execute_cm_cli(..., uv_compile=True)\n  │     │\n  │     ▼\n  │  cm_cli_util.py:execute_cm_cli()\n  │     │  → cmd += [\"--uv-compile\"]\n  │     │\n  │     ▼\n  │  subprocess: python -m cm_cli install <nodes> --uv-compile\n  │     │\n  │     ▼\n  │  cm_cli → UnifiedDepResolver → uv pip compile → pip install\n  │\n  ├─ comfy manager uv-compile-default true\n  │     │\n  │     ▼\n  │  command.py:uv_compile_default()\n  │     │  → ConfigManager.set(\"uv_compile_default\", \"True\")\n  │     │  → config.ini [DEFAULT] section\n  │\n  └─ comfy node uv-sync\n        │\n        ▼\n     execute_cm_cli([\"uv-sync\"])\n        │\n        ▼\n     subprocess: python -m cm_cli uv-sync\n```\n\n## File Changes\n\n### 1. `comfy_cli/constants.py`\n\n```python\nCONFIG_KEY_UV_COMPILE_DEFAULT = \"uv_compile_default\"\n```\n\nINI config key. Stored as `\"True\"` / `\"False\"` string in `[DEFAULT]` section.\n\n### 2. `comfy_cli/command/custom_nodes/cm_cli_util.py`\n\nAdded `uv_compile=False` parameter to `execute_cm_cli()`:\n\n```python\ndef execute_cm_cli(args, channel=None, fast_deps=False, no_deps=False,\n                   uv_compile=False, mode=None, raise_on_error=False):\n```\n\nFlag pass-through logic (added alongside existing `fast_deps`/`no_deps` branch):\n\n```python\nif uv_compile:\n    cmd += [\"--uv-compile\"]\nelif fast_deps or no_deps:\n    cmd += [\"--no-deps\"]\n```\n\n`uv_compile` takes priority over `fast_deps`/`no_deps`. By the time this\nfunction is called, the value is already resolved to a plain `bool` — no\n`None` handling needed here.\n\n### 3. `comfy_cli/command/custom_nodes/command.py`\n\n#### 3.1 Tri-state flag pattern\n\nAll 7 commands changed `uv_compile` parameter to `bool | None`:\n\n```python\nuv_compile: Annotated[\n    bool | None,\n    typer.Option(\n        \"--uv-compile/--no-uv-compile\",\n        show_default=False,\n        help=\"After {verb}, batch-resolve all dependencies via uv pip compile ...\",\n    ),\n] = None,\n```\n\ntyper's `--flag/--no-flag` pattern:\n- `--uv-compile` → `True`\n- `--no-uv-compile` → `False`\n- not specified → `None`\n\n#### 3.2 Resolution helper\n\n```python\ndef _resolve_uv_compile(\n    uv_compile: bool | None,\n    fast_deps: bool = False,\n    no_deps: bool = False,\n) -> bool:\n```\n\n**Resolution priority:**\n\n```\nuv_compile is True  → return True   (explicit --uv-compile)\nuv_compile is False → return False  (explicit --no-uv-compile)\nuv_compile is None  → check config:\n  config == \"True\" AND (fast_deps or no_deps) → return False  (conflict: explicit flag wins)\n  config == \"True\"                            → return True   (config default)\n  otherwise                                   → return False  (no config)\n```\n\nEach command passes the appropriate conflicting flags:\n\n| Command | Call |\n|---------|------|\n| `install` | `_resolve_uv_compile(uv_compile, fast_deps, no_deps)` |\n| `reinstall` | `_resolve_uv_compile(uv_compile, fast_deps=fast_deps)` |\n| Other 5 | `_resolve_uv_compile(uv_compile)` |\n\n#### 3.3 Mutual exclusivity validation\n\n**install** (3-way):\n\n```python\nexclusive_flags = [\n    name for name, val in\n    [(\"--fast-deps\", fast_deps), (\"--no-deps\", no_deps), (\"--uv-compile\", uv_compile)]\n    if val\n]\nif len(exclusive_flags) > 1:\n    typer.echo(f\"Cannot use {' and '.join(exclusive_flags)} together\", err=True)\n    raise typer.Exit(code=1)\n```\n\n`uv_compile=None` is falsy, so it is not included in the list. Config-resolved\nvalues are not checked here — only the raw flag value — so config defaults\nnever trigger mutual exclusivity errors.\n\n**reinstall** (2-way):\n\n```python\nif fast_deps and uv_compile is True:\n    typer.echo(\"Cannot use --fast-deps and --uv-compile together\", err=True)\n    raise typer.Exit(code=1)\n```\n\n`is True` identity check explicitly excludes `None`.\n\n#### 3.4 Manager config command\n\n```python\n@manager_app.command(\"uv-compile-default\")\ndef uv_compile_default(\n    enabled: Annotated[bool, typer.Argument(help=\"true to enable, false to disable\")],\n):\n    config_manager = ConfigManager()\n    config_manager.set(constants.CONFIG_KEY_UV_COMPILE_DEFAULT, str(enabled))\n```\n\ntyper automatically parses `true`/`false` strings to `bool`.\n`ConfigManager.set()` writes to `config.ini` immediately.\n\n#### 3.5 Standalone command\n\n```python\n@app.command(\"uv-sync\")\ndef uv_sync():\n    execute_cm_cli([\"uv-sync\"])\n```\n\nIndependent of config default. Always directly invokes cm_cli's `uv-sync`\nsubcommand.\n\n## Data Flow\n\n### Config storage\n\n```ini\n# ~/.config/comfy-cli/config.ini\n[DEFAULT]\nuv_compile_default = True\n```\n\n`ConfigManager.get(\"uv_compile_default\")` → `\"True\"` | `\"False\"` | `None`\n\n### Flag resolution flow\n\n```\nCLI input\n  │\n  ├─ --uv-compile    → uv_compile = True\n  ├─ --no-uv-compile → uv_compile = False\n  └─ (none)          → uv_compile = None\n                           │\n                    _resolve_uv_compile()\n                           │\n                    ┌──────┴──────┐\n                    │  not None?  │\n                    └──────┬──────┘\n                      Yes  │  No\n                      │    │\n                  return   ▼\n                  as-is  config.ini\n                           │\n                    ┌──────┴──────┐\n                    │  == \"True\"? │\n                    └──────┬──────┘\n                      Yes  │  No\n                      │    │\n                      ▼    └→ return False\n                  conflicting\n                  flags?\n                    │\n               Yes  │  No\n               │    │\n          return  return\n          False   True\n```\n\n### Subprocess command construction\n\n```\nexecute_cm_cli([\"install\", \"node-a\"], uv_compile=True)\n→ [python, -m, cm_cli, install, node-a, --uv-compile]\n\nexecute_cm_cli([\"install\", \"node-a\"], fast_deps=True)\n→ [python, -m, cm_cli, install, node-a, --no-deps]\n  + DependencyCompiler.compile_deps() / install_deps()\n\nexecute_cm_cli([\"install\", \"node-a\"], uv_compile=False)\n→ [python, -m, cm_cli, install, node-a]\n  (no extra flags — default per-node pip install)\n```\n\n## Test Strategy\n\n### Existing tests (regression)\n\nAll 207 existing tests pass. Changes to `--uv-compile` do not affect existing behavior.\n\n### Recommended new tests\n\n| Test | Verifies |\n|------|----------|\n| `test_resolve_uv_compile_explicit_true` | Explicit True → True |\n| `test_resolve_uv_compile_explicit_false` | Explicit False → False |\n| `test_resolve_uv_compile_config_true` | None + config True → True |\n| `test_resolve_uv_compile_config_false` | None + config False → False |\n| `test_resolve_uv_compile_config_none` | None + config None → False |\n| `test_resolve_uv_compile_config_with_fast_deps` | None + config True + fast_deps → False |\n| `test_resolve_uv_compile_config_with_no_deps` | None + config True + no_deps → False |\n| `test_install_mutual_exclusivity` | --uv-compile + --fast-deps → exit 1 |\n| `test_install_config_no_exclusivity` | config True + --fast-deps → no error |\n| `test_manager_uv_compile_default_enable` | Config stores \"True\" |\n| `test_manager_uv_compile_default_disable` | Config stores \"False\" |\n\n## Compatibility Matrix\n\n| comfy-cli | ComfyUI-Manager | Behavior |\n|-----------|-----------------|----------|\n| This change | v4.1+ | `--uv-compile` works correctly |\n| This change | v4.0 or older | cm_cli returns unknown flag error |\n| Previous version | v4.1+ | `--uv-compile` unavailable (no flag) |\n| Previous version | v4.0 or older | Existing behavior unchanged |\n"
  },
  {
    "path": "docs/PRD-uv-compile.md",
    "content": "# PRD: Unified Dependency Resolution (--uv-compile) Support\n\n## Overview\n\nAdd `--uv-compile` flag to comfy-cli to integrate with ComfyUI-Manager v4.1+'s\nUnified Dependency Resolver. Users can batch-resolve all custom node dependencies\nvia `uv pip compile` after install/update operations.\n\n## Background\n\n### Problem\n\nEach ComfyUI custom node ships its own `requirements.txt`. The default approach\n(`pip install` per node) frequently causes dependency conflicts between nodes.\nComfyUI-Manager v4.1+ introduced `UnifiedDepResolver` to solve this, but\ncomfy-cli had no way to invoke it.\n\n### Existing Approaches\n\n| Approach | Flag | Implementation | Behavior |\n|----------|------|----------------|----------|\n| Default | (none) | cm_cli | Per-node `pip install` |\n| Fast deps | `--fast-deps` | comfy-cli `DependencyCompiler` | comfy-cli side `uv pip compile` |\n| No deps | `--no-deps` | cm_cli | Skip dependency installation |\n| **Unified** | **`--uv-compile`** | **cm_cli `UnifiedDepResolver`** | **cm_cli side batch resolution** |\n\n### Target Users\n\n- ComfyUI-Manager v4.1+ users\n- Users managing many custom nodes\n- Users experiencing dependency conflicts\n\n## Requirements\n\n### FR-1: Add --uv-compile flag to 7 commands\n\n**Target commands:**\n\n| # | Command | Existing dep flags |\n|---|---------|-------------------|\n| 1 | `comfy node install` | `--fast-deps`, `--no-deps` |\n| 2 | `comfy node reinstall` | `--fast-deps` |\n| 3 | `comfy node update` | (none) |\n| 4 | `comfy node fix` | (none) |\n| 5 | `comfy node restore-snapshot` | (none) |\n| 6 | `comfy node restore-dependencies` | (none) |\n| 7 | `comfy node install-deps` | (none) |\n\n**Behavior:** When the flag is passed, append `--uv-compile` to the cm_cli\nsubprocess command.\n\n### FR-2: Standalone uv-sync command\n\n```\ncomfy node uv-sync\n```\n\nDirectly invokes cm_cli's `uv-sync` subcommand. Batch-resolves all installed\ncustom node dependencies without requiring a prior install/update operation.\n\n### FR-3: --no-uv-compile flag\n\nAdd `--no-uv-compile` to all 7 commands so users can explicitly disable the\nconfig default on a per-command basis.\n\n### FR-4: Config default setting\n\n```\ncomfy manager uv-compile-default true   # Enable by default\ncomfy manager uv-compile-default false  # Disable by default\n```\n\nOnce enabled, `--uv-compile` is automatically applied to all custom node\noperations.\n\n### FR-5: Mutual exclusivity\n\n`--uv-compile`, `--fast-deps`, and `--no-deps` are mutually exclusive.\n\n| Combination | Result |\n|-------------|--------|\n| `--uv-compile --fast-deps` | Error |\n| `--uv-compile --no-deps` | Error |\n| `--fast-deps --no-deps` | Error |\n| config default + `--fast-deps` | `--fast-deps` wins (no error) |\n| config default + `--no-uv-compile` | Disabled |\n\n### NFR-1: Backward compatibility\n\n- No impact on existing `--fast-deps` / `--no-deps` behavior\n- Without flag and without config, behavior is identical to before (per-node pip install)\n\n### NFR-2: Minimum version\n\nRequires ComfyUI-Manager v4.1+. On older versions, cm_cli returns its own\nerror for the unknown flag. comfy-cli does not perform version checking.\n\n## Out of Scope\n\n- `comfy install` (core ComfyUI installation) — separate dependency system\n- Modifications to cm_cli's internal UnifiedDepResolver logic\n- Automatic ComfyUI-Manager version detection\n"
  },
  {
    "path": "docs/TESTING-e2e.md",
    "content": "# E2E Testing Guide\n\nE2E tests perform real `comfy install`, `comfy launch`, and `comfy node` operations.\nThey are **disabled by default** and must be explicitly enabled.\n\n## Environment variables\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `TEST_E2E` | `false` | Set to `true` to enable E2E tests |\n| `TEST_E2E_COMFY_URL` | *(empty — uses default)* | Custom ComfyUI repo URL. Supports `@branch` syntax |\n| `TEST_E2E_COMFY_INSTALL_FLAGS` | `--cpu` | Extra flags passed to `comfy install` |\n| `TEST_E2E_COMFY_LAUNCH_FLAGS_EXTRA` | `--cpu` | Extra flags passed to `comfy launch` |\n\n## Basic usage\n\n```bash\nTEST_E2E=true pytest tests/e2e/\n```\n\nInstalls ComfyUI from the default upstream (`Comfy-Org/ComfyUI`), launches it\nin the background, runs the test suite, then stops the server.\n\n## Pre-release testing\n\nTo test features that depend on unreleased ComfyUI changes (e.g.\n`manager_requirements.txt` not yet merged upstream), point the E2E suite at a\nfork/branch:\n\n```bash\nTEST_E2E=true \\\nTEST_E2E_COMFY_URL=\"https://github.com/ltdrdata/ComfyUI.git@dr-bump-manager\" \\\npytest tests/e2e/ -v\n```\n\nThis clones `ltdrdata/ComfyUI` at the `dr-bump-manager` branch, which contains\n`manager_requirements.txt` for pip-based Manager v4 installation.\n\n### Full pre-release run (GPU)\n\n```bash\nTEST_E2E=true \\\nTEST_E2E_COMFY_URL=\"https://github.com/ltdrdata/ComfyUI.git@dr-bump-manager\" \\\nTEST_E2E_COMFY_INSTALL_FLAGS=\"\" \\\nTEST_E2E_COMFY_LAUNCH_FLAGS_EXTRA=\"\" \\\npytest tests/e2e/ -v\n```\n\n## Test suites\n\n### `test_e2e.py` — General functionality\n\nCovers model download, custom node lifecycle, workflow execution, and basic\nManager v4 smoke tests.\n\n| Test | Description |\n|------|-------------|\n| `test_model` | Download, list, and remove a model |\n| `test_node` | Install, reinstall, show, update, disable, enable, publish a custom node |\n| `test_manager_installed` | Verifies `cm_cli` is importable after install |\n| `test_node_uv_compile` | Installs a node with `--uv-compile` and runs `comfy node uv-sync` |\n| `test_uv_compile_default_config` | Sets `uv-compile-default`, verifies `comfy env` display |\n| `test_run` | Downloads a checkpoint and executes a workflow end-to-end |\n\n### `test_e2e_uv_compile.py` — Unified dependency resolution\n\nComprehensive `--uv-compile` E2E suite. **Requires Manager v4.1+** — automatically\nskipped when `cm_cli` is not importable.\n\n#### Test packs\n\nTwo categories of node packs are used:\n\n- **Real packs** (`comfyui-impact-pack`, `comfyui-inspire-pack`) — production\n  node packs for verifying normal installation succeeds without conflicts.\n- **Conflict fixture packs** (`nodepack-test1-do-not-install`,\n  `nodepack-test2-do-not-install`) — ltdrdata's dedicated test packs that\n  intentionally conflict on ansible versions (`ansible==9.13.0` vs\n  `ansible-core==2.14.0`). Contain no executable code.\n\nSupply-chain safety: only node packs from verified authors (ltdrdata,\ncomfyanonymous, Comfy-Org) are used.\n\n#### Test scenarios\n\n**Normal installation (real packs)**\n\n| Test | Scenario | Packs |\n|------|----------|-------|\n| `test_real_packs_sequential_no_conflict` | Install two real packs one-by-one with `--uv-compile` — each resolves successfully, no conflicts | impact, inspire |\n| `test_real_packs_simultaneous_no_conflict` | Install two real packs in a single command with `--uv-compile` — resolves successfully, no conflicts | impact, inspire |\n\n**Progressive conflict**\n\n| Test | Scenario | Packs |\n|------|----------|-------|\n| `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 |\n\n**Command coverage (--uv-compile flag on each command)**\n\n| Test | Scenario | Packs |\n|------|----------|-------|\n| `test_node_reinstall_uv_compile` | Reinstall an installed pack with `--uv-compile` — resolution runs | test1 |\n| `test_node_update_uv_compile` | Update an installed pack with `--uv-compile` — resolution runs | test1 |\n| `test_node_fix_uv_compile` | Fix an installed pack with `--uv-compile` — resolution runs | test1 |\n| `test_node_restore_deps_uv_compile` | `restore-dependencies --uv-compile` — resolution runs | test1 |\n\n**Standalone uv-sync**\n\n| Test | Scenario | Packs |\n|------|----------|-------|\n| `test_node_uv_sync_standalone` | `comfy node uv-sync` resolves installed pack dependencies | test1 |\n| `test_node_uv_sync_standalone_conflict` | `comfy node uv-sync` with conflicting packs — shows conflict attribution | test1, test2 |\n\n**Config default and overrides**\n\n| Test | Scenario | Packs |\n|------|----------|-------|\n| `test_uv_compile_config_default` | `uv-compile-default true` → install without flag triggers resolution | test1 |\n| `test_no_uv_compile_overrides_config` | Config default enabled, `--no-uv-compile` overrides — resolution does not run | test1 |\n\n**Mutual exclusivity**\n\n| Test | Scenario | Packs |\n|------|----------|-------|\n| `test_uv_compile_mutual_exclusivity` | `--uv-compile` with `--fast-deps` or `--no-deps` — rejected with error | test1 |\n\n#### Fixtures and isolation\n\n- `workspace` (module-scoped): installs ComfyUI, launches server in background,\n  yields workspace path, stops server on teardown.\n- `comfy_cli`: returns `comfy --workspace {ws}` command prefix.\n- `_clean_test_packs` (autouse, function-scoped): removes conflict fixture packs\n  before and after each test. Real packs are **not** removed between tests\n  (they persist in the workspace).\n- Config default tests use `try/finally` to restore the setting after each test.\n\n## Notes\n\n- E2E tests create a temporary workspace directory (`comfy-<timestamp>`) in the\n  current working directory. It is **not** automatically cleaned up.\n- Each test file has its own `workspace` fixture (`module`-scoped) — all tests\n  within a file share a single ComfyUI installation.\n- Tests that require Manager v4 are automatically skipped when `cm_cli` is not\n  importable.\n"
  },
  {
    "path": "pylock.toml",
    "content": "# This file was autogenerated by uv via the following command:\n#    uv export --output-file=pylock.toml\nlock-version = \"1.0\"\ncreated-by = \"uv\"\nrequires-python = \">=3.10\"\n\n[[packages]]\nname = \"anyio\"\nversion = \"4.10.0\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"arrow\"\nversion = \"1.3.0\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"binaryornot\"\nversion = \"0.4.4\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"certifi\"\nversion = \"2025.8.3\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"chardet\"\nversion = \"5.2.0\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"charset-normalizer\"\nversion = \"3.4.2\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n]\n\n[[packages]]\nname = \"click\"\nversion = \"8.1.8\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"colorama\"\nversion = \"0.4.6\"\nmarker = \"sys_platform == 'win32'\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"comfy-cli\"\ndirectory = { path = \".\", editable = true }\n\n[[packages]]\nname = \"cookiecutter\"\nversion = \"2.6.0\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"exceptiongroup\"\nversion = \"1.3.0\"\nmarker = \"python_full_version < '3.11'\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"gitdb\"\nversion = \"4.0.12\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"gitpython\"\nversion = \"3.1.45\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"h11\"\nversion = \"0.16.0\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"httpcore\"\nversion = \"1.0.9\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"httpx\"\nversion = \"0.28.1\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"idna\"\nversion = \"3.10\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"jinja2\"\nversion = \"3.1.6\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"markdown-it-py\"\nversion = \"3.0.0\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"markupsafe\"\nversion = \"3.0.2\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n]\n\n[[packages]]\nname = \"mdurl\"\nversion = \"0.1.2\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"mixpanel\"\nversion = \"4.10.1\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"packaging\"\nversion = \"25.0\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"pathspec\"\nversion = \"0.12.1\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"prompt-toolkit\"\nversion = \"3.0.51\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"psutil\"\nversion = \"7.0.0\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n]\n\n[[packages]]\nname = \"pygments\"\nversion = \"2.19.2\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"python-dateutil\"\nversion = \"2.9.0.post0\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"python-slugify\"\nversion = \"8.0.4\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"pyyaml\"\nversion = \"6.0.2\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n]\n\n[[packages]]\nname = \"questionary\"\nversion = \"2.1.0\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"requests\"\nversion = \"2.32.4\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"rich\"\nversion = \"14.1.0\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"ruff\"\nversion = \"0.12.7\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n]\n\n[[packages]]\nname = \"semver\"\nversion = \"3.0.4\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"shellingham\"\nversion = \"1.5.4\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"six\"\nversion = \"1.17.0\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"smmap\"\nversion = \"5.0.2\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"sniffio\"\nversion = \"1.3.1\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"text-unidecode\"\nversion = \"1.3\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"tomlkit\"\nversion = \"0.13.3\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"typer\"\nversion = \"0.21.1\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"types-python-dateutil\"\nversion = \"2.9.0.20250708\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"typing-extensions\"\nversion = \"4.14.1\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"urllib3\"\nversion = \"2.5.0\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"uv\"\nversion = \"0.8.5\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n    { 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\" } },\n]\n\n[[packages]]\nname = \"wcwidth\"\nversion = \"0.2.13\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n\n[[packages]]\nname = \"websocket-client\"\nversion = \"1.8.0\"\nindex = \"https://pypi.org/simple\"\nsdist = { 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\" } }\nwheels = [{ 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\" } }]\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[build-system]\nbuild-backend = \"setuptools.build_meta\"\n\nrequires = [ \"setuptools>=61\" ]\n\n[project]\nname = \"comfy-cli\"\nversion = \"0.0.0\"                                            # Will be filled in by the CI/CD pipeline. Check publish_package.py.\ndescription = \"A CLI tool for installing and using ComfyUI.\"\nreadme = \"README.md\"\nkeywords = [ \"comfyui\", \"stable diffusion\" ]\n\nlicense = { text = \"GPL-3.0-only\" }\nmaintainers = [\n  { name = \"Yoland Yan\", email = \"yoland@drip.art\" },\n  { name = \"James Kwon\", email = \"hongilkwon316@gmail.com\" },\n  { name = \"Robin Huang\", email = \"robin@drip.art\" },\n  { name = \"Dr.Lt.Data\", email = \"dr.lt.data@gmail.com\" },\n]\n\nrequires-python = \">=3.10\"\nclassifiers = [\n  \"Development Status :: 4 - Beta\",\n  \"Intended Audience :: Developers\",\n  \"License :: OSI Approved :: GNU General Public License v3 (GPLv3)\",\n  \"Programming Language :: Python :: 3 :: Only\",\n  \"Programming Language :: Python :: 3.10\",\n  \"Programming Language :: Python :: 3.11\",\n  \"Programming Language :: Python :: 3.12\",\n  \"Programming Language :: Python :: 3.13\",\n]\n\ndependencies = [\n  \"charset-normalizer>=3\",\n  \"cookiecutter\",\n  \"gitpython\",\n  \"httpx\",\n  \"mixpanel\",\n  \"packaging\",\n  \"pathspec\",\n  \"psutil\",\n  \"pyyaml\",\n  \"questionary\",\n  \"requests\",\n  \"rich\",\n  \"ruff\",\n  \"semver~=3.0.2\",\n  \"tomlkit\",\n  \"typer>=0.12.5\",\n  \"typing-extensions>=4.7\",\n  \"uv>=0.6.9\",\n  \"websocket-client\",\n]\n\noptional-dependencies.dev = [ \"pre-commit\", \"pytest\", \"pytest-cov\", \"ruff\" ]\nurls.Repository = \"https://github.com/Comfy-Org/comfy-cli.git\"\nscripts.comfy = \"comfy_cli.__main__:main\"\nscripts.comfy-cli = \"comfy_cli.__main__:main\"\nscripts.comfycli = \"comfy_cli.__main__:main\"\n\n[tool.setuptools.packages.find]\nwhere = [ \".\" ]\ninclude = [ \"comfy_cli*\" ]\n\n[tool.ruff]\ntarget-version = \"py310\"\n\nline-length = 120\nlint.select = [\n  \"E\",      # pycodestyle - Error\n  \"F\",      # default\n  \"I\",      # isort-like behavior (import statement sorting)\n  \"Q\",      # flake8-quotes\n  \"RET504\", # Unnecessary assignment to {name} before return statement\n  \"UP\",     # pyupgrade\n  \"W\",      # pycodestyle - Warning\n]\nlint.extend-ignore = [\n  \"E501\", # Line too long\n]\n"
  },
  {
    "path": "pyrightconfig.json",
    "content": "{\n    \"pythonPlatform\": \"All\", \n}"
  },
  {
    "path": "tests/comfy_cli/command/generate/__init__.py",
    "content": ""
  },
  {
    "path": "tests/comfy_cli/command/generate/test_adapters.py",
    "content": "\"\"\"Tests for the per-endpoint adapters: Gemini (nano-banana) and Seedance.\"\"\"\n\nfrom __future__ import annotations\n\nimport base64\n\nimport httpx\nimport pytest\n\nfrom comfy_cli.command.generate import adapters, client, output, schema, spec\n\n# ── Gemini / nano-banana ─────────────────────────────────────────────────\n\n\ndef test_nano_banana_alias_resolves():\n    ep = spec.get_endpoint(\"nano-banana\")\n    assert ep.id == \"vertexai/gemini/{model}\"\n    assert ep.polling is None\n    assert ep.partner == \"vertexai\"\n\n\ndef test_gemini_adapter_overrides_schema_flags():\n    \"\"\"Schema-derived flags would be `contents`/`tools`/…; the adapter\n    swaps in a friendlier `prompt`/`image`/`model` triple.\"\"\"\n    ep = spec.get_endpoint(\"nano-banana\")\n    names = [f.name for f in schema.flags_for(ep)]\n    assert names == [\"prompt\", \"image\", \"model\"]\n\n\ndef test_gemini_build_body_text_only():\n    body = adapters._gemini_build_body({\"prompt\": \"a fox\"}, api_key=\"k\")\n    assert body[\"contents\"][0][\"role\"] == \"user\"\n    parts = body[\"contents\"][0][\"parts\"]\n    assert parts == [{\"text\": \"a fox\"}]\n    assert body[\"generationConfig\"][\"responseModalities\"] == [\"IMAGE\"]\n\n\ndef test_gemini_build_body_inlines_local_image(tmp_path):\n    img = tmp_path / \"ref.png\"\n    img.write_bytes(b\"\\x89PNG\\r\\n\\x1a\\n-bytes-\")\n    body = adapters._gemini_build_body(\n        {\"prompt\": \"add hat\", \"image\": [str(img)]},\n        api_key=\"k\",\n    )\n    parts = body[\"contents\"][0][\"parts\"]\n    assert parts[0] == {\"text\": \"add hat\"}\n    inline = parts[1][\"inlineData\"]\n    assert inline[\"mimeType\"] == \"image/png\"\n    assert base64.b64decode(inline[\"data\"]) == b\"\\x89PNG\\r\\n\\x1a\\n-bytes-\"\n\n\ndef test_gemini_build_body_inlines_remote_url(monkeypatch):\n    class FakeClient:\n        def __init__(self, *a, **kw):\n            pass\n\n        def __enter__(self):\n            return self\n\n        def __exit__(self, *a):\n            pass\n\n        def get(self, url):\n            return httpx.Response(\n                200,\n                content=b\"jpeg-bytes\",\n                headers={\"content-type\": \"image/jpeg\"},\n                request=httpx.Request(\"GET\", url),\n            )\n\n    monkeypatch.setattr(adapters.httpx, \"Client\", FakeClient)\n    body = adapters._gemini_build_body(\n        {\"prompt\": \"x\", \"image\": [\"https://example.com/a.jpg\"]},\n        api_key=\"k\",\n    )\n    inline = body[\"contents\"][0][\"parts\"][1][\"inlineData\"]\n    assert inline[\"mimeType\"] == \"image/jpeg\"\n    assert base64.b64decode(inline[\"data\"]) == b\"jpeg-bytes\"\n\n\ndef test_gemini_build_body_inlines_data_uri():\n    blob = base64.b64encode(b\"png-bytes\").decode(\"ascii\")\n    body = adapters._gemini_build_body(\n        {\"prompt\": \"x\", \"image\": f\"data:image/png;base64,{blob}\"},\n        api_key=\"k\",\n    )\n    inline = body[\"contents\"][0][\"parts\"][1][\"inlineData\"]\n    assert inline[\"mimeType\"] == \"image/png\"\n    assert inline[\"data\"] == blob\n\n\ndef test_gemini_inline_image_missing_path_raises(tmp_path):\n    with pytest.raises(client.ApiError, match=\"not found\"):\n        adapters._inline_image(str(tmp_path / \"nope.png\"))\n\n\ndef test_gemini_decode_sync_saves_inline_blobs(tmp_path):\n    blob = base64.b64encode(b\"png-payload\").decode(\"ascii\")\n    body = {\"candidates\": [{\"content\": {\"parts\": [{\"inlineData\": {\"mimeType\": \"image/png\", \"data\": blob}}]}}]}\n    out = tmp_path / \"out.png\"\n    saved = adapters._gemini_decode_sync(body, str(out), \"req-1\")\n    assert saved == [out]\n    assert out.read_bytes() == b\"png-payload\"\n\n\ndef test_gemini_decode_sync_handles_snake_case_keys(tmp_path):\n    \"\"\"Gemini responses are sometimes serialized as inline_data/mime_type.\n    With a directory-shorthand template, the mime drives the extension.\"\"\"\n    blob = base64.b64encode(b\"webp-payload\").decode(\"ascii\")\n    body = {\"candidates\": [{\"content\": {\"parts\": [{\"inline_data\": {\"mime_type\": \"image/webp\", \"data\": blob}}]}}]}\n    saved = adapters._gemini_decode_sync(body, str(tmp_path) + \"/\", \"r\")\n    assert len(saved) == 1\n    assert saved[0].read_bytes() == b\"webp-payload\"\n    assert saved[0].suffix == \".webp\"\n\n\ndef test_gemini_decode_sync_returns_empty_when_blocked(tmp_path):\n    body = {\"candidates\": [{\"finishReason\": \"SAFETY\", \"content\": {\"parts\": []}}]}\n    saved = adapters._gemini_decode_sync(body, str(tmp_path / \"x.png\"), \"r\")\n    assert saved == []\n\n\ndef test_gemini_resolve_path_substitutes_model():\n    ep = spec.get_endpoint(\"nano-banana\")\n    adapter = adapters.get(ep.id)\n    url = adapters.resolve_path(ep.path, {\"model\": \"gemini-2.5-flash-image\"}, adapter)\n    assert url == \"/proxy/vertexai/gemini/gemini-2.5-flash-image\"\n\n\ndef test_gemini_send_request_hits_substituted_path(monkeypatch):\n    captured = {}\n\n    def fake_post(url, *, json=None, headers=None, timeout=None, **_kw):\n        captured[\"url\"] = url\n        captured[\"json\"] = json\n        return httpx.Response(200, json={\"candidates\": []})\n\n    monkeypatch.setattr(client.httpx, \"post\", fake_post)\n    ep = spec.get_endpoint(\"nano-banana\")\n    flags = schema.flags_for(ep)\n    client.send_request(\n        ep,\n        {\"prompt\": \"hi\", \"model\": \"gemini-2.5-flash-image\"},\n        flags,\n        api_key=\"comfyui-test\",\n    )\n    assert captured[\"url\"].endswith(\"/proxy/vertexai/gemini/gemini-2.5-flash-image\")\n    assert captured[\"json\"][\"contents\"][0][\"parts\"][0][\"text\"] == \"hi\"\n\n\n# ── Seedance ──────────────────────────────────────────────────────────────\n\n\ndef test_seedance_alias_resolves():\n    ep = spec.get_endpoint(\"seedance\")\n    assert ep.id == \"byteplus/api/v3/contents/generations/tasks\"\n    assert ep.polling == \"seedance\"\n    assert ep.partner == \"byteplus\"\n\n\ndef test_seedance_adapter_overrides_flags():\n    ep = spec.get_endpoint(\"seedance\")\n    names = [f.name for f in schema.flags_for(ep)]\n    assert \"prompt\" in names\n    assert \"model\" in names\n    assert \"resolution\" in names\n    assert \"ratio\" in names\n    assert \"duration\" in names\n\n\ndef test_seedance_build_body_text_only():\n    body = adapters._seedance_build_body({\"prompt\": \"a wave\"}, api_key=\"k\")\n    assert body[\"model\"] == \"seedance-1-0-pro-250528\"\n    assert body[\"content\"] == [{\"type\": \"text\", \"text\": \"a wave\"}]\n\n\ndef test_seedance_build_body_inlines_knobs_into_text():\n    body = adapters._seedance_build_body(\n        {\n            \"prompt\": \"a boat\",\n            \"resolution\": \"720p\",\n            \"ratio\": \"16:9\",\n            \"duration\": 5,\n            \"fps\": 24,\n            \"camerafixed\": True,\n        },\n        api_key=\"k\",\n    )\n    text = body[\"content\"][0][\"text\"]\n    assert text.startswith(\"a boat \")\n    assert \"--resolution 720p\" in text\n    assert \"--ratio 16:9\" in text\n    assert \"--duration 5\" in text\n    assert \"--fps 24\" in text\n    assert \"--camerafixed true\" in text\n\n\ndef test_seedance_build_body_uploads_local_image(monkeypatch, tmp_path):\n    \"\"\"Local paths get pushed through /customers/storage and replaced with the\n    returned signed URL — we shouldn't see the path appear in the body.\"\"\"\n    img = tmp_path / \"ref.png\"\n    img.write_bytes(b\"ref\")\n\n    from comfy_cli.command.generate import upload\n\n    def fake_upload_path(path, api_key):\n        return upload.UploadResult(url=\"https://cdn/signed-ref.png\", expires_at=None, existing_file=False)\n\n    monkeypatch.setattr(upload, \"upload_path\", fake_upload_path)\n    body = adapters._seedance_build_body(\n        {\"prompt\": \"wave\", \"image\": str(img)},\n        api_key=\"comfyui-test\",\n    )\n    image_part = body[\"content\"][1]\n    assert image_part == {\"type\": \"image_url\", \"image_url\": {\"url\": \"https://cdn/signed-ref.png\"}}\n\n\ndef test_seedance_build_body_keeps_remote_url_verbatim(monkeypatch):\n    \"\"\"Remote URLs and data: URIs are pass-through — no re-upload.\"\"\"\n    from comfy_cli.command.generate import upload\n\n    def boom(*a, **kw):\n        raise AssertionError(\"upload should not be called for remote URLs\")\n\n    monkeypatch.setattr(upload, \"upload_path\", boom)\n    body = adapters._seedance_build_body(\n        {\"prompt\": \"x\", \"image\": \"https://example.com/a.jpg\"},\n        api_key=\"k\",\n    )\n    assert body[\"content\"][1][\"image_url\"][\"url\"] == \"https://example.com/a.jpg\"\n\n\ndef test_seedance_build_body_includes_audio_flag():\n    body = adapters._seedance_build_body(\n        {\"prompt\": \"x\", \"model\": \"seedance-1-5-pro-251215\", \"generate_audio\": True},\n        api_key=\"k\",\n    )\n    assert body[\"generate_audio\"] is True\n    assert body[\"model\"] == \"seedance-1-5-pro-251215\"\n\n\ndef test_seedance_send_request_passes_through_body(monkeypatch):\n    captured = {}\n\n    def fake_post(url, *, json=None, headers=None, timeout=None, **_kw):\n        captured[\"url\"] = url\n        captured[\"json\"] = json\n        return httpx.Response(200, json={\"id\": \"task-1\"})\n\n    monkeypatch.setattr(client.httpx, \"post\", fake_post)\n    ep = spec.get_endpoint(\"seedance\")\n    flags = schema.flags_for(ep)\n    client.send_request(ep, {\"prompt\": \"x\"}, flags, api_key=\"comfyui-test\")\n    assert captured[\"url\"].endswith(\"/proxy/byteplus/api/v3/contents/generations/tasks\")\n    assert captured[\"json\"][\"model\"]\n    assert captured[\"json\"][\"content\"][0][\"type\"] == \"text\"\n\n\n# ── Seedance polling ──────────────────────────────────────────────────────\n\n\ndef test_seedance_poll_url_and_success_extraction(monkeypatch):\n    \"\"\"Driver should hit the task-status endpoint and pluck the video_url.\"\"\"\n    from comfy_cli.command.generate import poll\n\n    monkeypatch.setattr(poll, \"_sleep\", lambda *_: None)\n    captured = {}\n\n    def fake_get(url, **_kw):\n        captured[\"url\"] = url\n        return httpx.Response(\n            200,\n            json={\"id\": \"t1\", \"status\": \"succeeded\", \"content\": {\"video_url\": \"https://cdn/v.mp4\"}},\n        )\n\n    monkeypatch.setattr(\"comfy_cli.command.generate.client.get\", fake_get)\n    result = poll.get_poller(\"seedance\")({\"id\": \"t1\"}, api_key=\"k\")\n    assert captured[\"url\"] == \"/proxy/byteplus/api/v3/contents/generations/tasks/t1\"\n    assert result.status == \"succeeded\"\n    assert \"https://cdn/v.mp4\" in result.image_urls\n\n\ndef test_seedance_poll_failure(monkeypatch):\n    from comfy_cli.command.generate import poll\n\n    monkeypatch.setattr(poll, \"_sleep\", lambda *_: None)\n    monkeypatch.setattr(\n        \"comfy_cli.command.generate.client.get\",\n        lambda *a, **kw: httpx.Response(200, json={\"id\": \"t1\", \"status\": \"failed\", \"error\": {\"code\": \"x\"}}),\n    )\n    result = poll.get_poller(\"seedance\")({\"id\": \"t1\"}, api_key=\"k\")\n    assert result.status == \"failed\"\n    assert \"failed\" in (result.error or \"\")\n\n\ndef test_seedance_resume_helper_round_trip():\n    \"\"\"`comfy generate resume` reverses the create-response into something the\n    poller can read — make sure that helper knows about seedance.\"\"\"\n    from comfy_cli.command.generate import poll\n\n    body = poll.build_synthetic_initial(\"seedance\", \"t-42\")\n    assert poll.extract_job_id(\"seedance\", body) == \"t-42\"\n\n\n# ── Inline blob saving ────────────────────────────────────────────────────\n\n\ndef test_save_inline_blobs_auto_indexes_multi(tmp_path):\n    blobs = [(\"image/png\", b\"a\"), (\"image/png\", b\"b\")]\n    saved = output.save_inline_blobs(blobs, str(tmp_path / \"out.png\"), \"req\")\n    assert len(saved) == 2\n    assert saved[0].name == \"out_0.png\"\n    assert saved[1].name == \"out_1.png\"\n    assert saved[0].read_bytes() == b\"a\"\n    assert saved[1].read_bytes() == b\"b\"\n\n\ndef test_save_inline_blobs_picks_extension_from_mime(tmp_path):\n    saved = output.save_inline_blobs([(\"image/webp\", b\"x\")], str(tmp_path) + \"/\", \"req\")\n    assert saved[0].suffix == \".webp\"\n"
  },
  {
    "path": "tests/comfy_cli/command/generate/test_app.py",
    "content": "\"\"\"End-to-end tests for ``comfy generate`` via Typer's CliRunner.\n\nThese cover the dispatch table (list/schema/refresh/resume vs. model alias) and\neach major run path with httpx mocked at the boundary.\n\"\"\"\n\nimport base64\nfrom pathlib import Path\n\nimport httpx\nimport pytest\nfrom typer.testing import CliRunner\n\nfrom comfy_cli.cmdline import app as cli_app\nfrom comfy_cli.command.generate import app as gen_app\n\n\n@pytest.fixture(autouse=True)\ndef disable_tracking_prompt(monkeypatch):\n    \"\"\"The mixpanel-consent prompt blocks Typer invocations in CI (no TTY).\n    Existing CLI tests pass --skip-prompt; we do the same here implicitly.\"\"\"\n    monkeypatch.setattr(\"comfy_cli.tracking.prompt_tracking_consent\", lambda *a, **kw: None)\n    monkeypatch.setattr(\"comfy_cli.tracking.track_event\", lambda *a, **kw: None)\n\n\n@pytest.fixture\ndef runner():\n    return CliRunner()\n\n\n@pytest.fixture\ndef api_key(monkeypatch):\n    monkeypatch.setenv(\"COMFY_API_KEY\", \"comfyui-test\")\n    return \"comfyui-test\"\n\n\n# ─── Dispatch / top-level help ────────────────────────────────────────────\n\n\ndef test_no_args_prints_top_help(runner):\n    r = runner.invoke(cli_app, [\"generate\"])\n    assert r.exit_code == 0\n    assert \"comfy generate\" in r.stdout\n    assert \"Examples\" in r.stdout\n\n\ndef test_top_help_via_dash_help(runner):\n    r = runner.invoke(cli_app, [\"generate\", \"--help\"])\n    assert r.exit_code == 0\n    assert \"comfy generate\" in r.stdout\n\n\n# ─── list ────────────────────────────────────────────────────────────────\n\n\ndef test_list_shows_aliases(runner):\n    r = runner.invoke(cli_app, [\"generate\", \"list\"])\n    assert r.exit_code == 0\n    assert \"flux-pro\" in r.stdout\n\n\ndef test_list_partner_filter(runner):\n    r = runner.invoke(cli_app, [\"generate\", \"list\", \"--partner\", \"bfl\"])\n    assert r.exit_code == 0\n    assert \"flux-pro\" in r.stdout\n    assert \"ideogram\" not in r.stdout\n\n\ndef test_list_partner_eq_form(runner):\n    r = runner.invoke(cli_app, [\"generate\", \"list\", \"--partner=bfl\"])\n    assert r.exit_code == 0\n    assert \"flux-pro\" in r.stdout\n\n\ndef test_list_style_filter(runner):\n    r = runner.invoke(cli_app, [\"generate\", \"list\", \"--style\", \"image-edit\"])\n    assert r.exit_code == 0\n    assert \"edit\" in r.stdout.lower()\n\n\ndef test_list_query_filter(runner):\n    r = runner.invoke(cli_app, [\"generate\", \"list\", \"--query\", \"ideogram\"])\n    assert r.exit_code == 0\n    assert \"ideogram\" in r.stdout\n\n\ndef test_list_no_matches(runner):\n    r = runner.invoke(cli_app, [\"generate\", \"list\", \"--partner\", \"nonexistent\"])\n    assert r.exit_code == 0\n    assert \"No models\" in r.stdout\n\n\n# ─── schema ──────────────────────────────────────────────────────────────\n\n\ndef test_schema_alias(runner):\n    r = runner.invoke(cli_app, [\"generate\", \"schema\", \"flux-pro\"])\n    assert r.exit_code == 0\n    assert \"prompt\" in r.stdout\n    assert \"Example\" in r.stdout\n\n\ndef test_schema_full_path(runner):\n    r = runner.invoke(cli_app, [\"generate\", \"schema\", \"bfl/flux-pro-1.1/generate\"])\n    assert r.exit_code == 0\n    assert \"prompt\" in r.stdout\n\n\ndef test_schema_missing_arg(runner):\n    r = runner.invoke(cli_app, [\"generate\", \"schema\"])\n    assert r.exit_code == 1\n    assert \"Usage\" in r.stdout\n\n\ndef test_schema_unknown_model(runner):\n    r = runner.invoke(cli_app, [\"generate\", \"schema\", \"bogus-model\"])\n    assert r.exit_code == 1\n    assert \"Unknown model\" in r.stdout\n\n\n# ─── per-model --help passes through to schema view ─────────────────────\n\n\ndef test_per_model_help(runner):\n    r = runner.invoke(cli_app, [\"generate\", \"flux-pro\", \"--help\"])\n    assert r.exit_code == 0\n    assert \"Model:\" in r.stdout\n    assert \"prompt\" in r.stdout\n\n\n# ─── generate happy / error paths ───────────────────────────────────────\n\n\ndef test_generate_missing_api_key(runner, monkeypatch):\n    monkeypatch.delenv(\"COMFY_API_KEY\", raising=False)\n    r = runner.invoke(\n        cli_app,\n        [\"generate\", \"flux-pro\", \"--prompt\", \"x\", \"--width\", \"1\", \"--height\", \"1\"],\n    )\n    assert r.exit_code == 1\n    assert \"No API key\" in r.stdout\n\n\ndef test_generate_bad_int_suggests_schema(runner, api_key):\n    r = runner.invoke(\n        cli_app,\n        [\"generate\", \"flux-pro\", \"--prompt\", \"x\", \"--width\", \"abc\", \"--height\", \"1\"],\n    )\n    assert r.exit_code == 1\n    assert \"expected integer\" in r.stdout\n    assert \"comfy generate schema\" in r.stdout\n\n\ndef test_generate_unknown_model(runner, api_key):\n    r = runner.invoke(cli_app, [\"generate\", \"bogus-name\", \"--prompt\", \"x\"])\n    assert r.exit_code == 1\n    assert \"Unknown model\" in r.stdout\n\n\ndef test_generate_missing_required(runner, api_key):\n    r = runner.invoke(cli_app, [\"generate\", \"flux-pro\", \"--prompt\", \"x\"])\n    assert r.exit_code == 1\n    assert \"Missing required\" in r.stdout\n\n\ndef test_generate_bad_timeout(runner, api_key, monkeypatch):\n    monkeypatch.setattr(\n        gen_app.client.httpx,\n        \"post\",\n        lambda *a, **kw: httpx.Response(200, json={\"id\": \"x\", \"polling_url\": \"https://x\"}),\n    )\n    r = runner.invoke(\n        cli_app,\n        [\"generate\", \"flux-pro\", \"--prompt\", \"x\", \"--width\", \"1\", \"--height\", \"1\", \"--timeout\", \"not-a-num\"],\n    )\n    assert r.exit_code == 1\n    assert \"--timeout\" in r.stdout\n\n\n# ─── generate: async polling path (BFL) ─────────────────────────────────\n\n\ndef test_generate_async_sync_poll_to_ready(runner, api_key, monkeypatch):\n    submit = httpx.Response(200, json={\"id\": \"job-xyz\", \"polling_url\": \"https://x/poll\"})\n    poll_done = httpx.Response(\n        200,\n        json={\n            \"status\": \"Ready\",\n            \"progress\": 1.0,\n            \"result\": {\"sample\": \"https://cdn.example/result.png\"},\n        },\n    )\n    monkeypatch.setattr(gen_app.client.httpx, \"post\", lambda *a, **kw: submit)\n    monkeypatch.setattr(\"comfy_cli.command.generate.client.get\", lambda *a, **kw: poll_done)\n    monkeypatch.setattr(\"comfy_cli.command.generate.poll._sleep\", lambda *_: None)\n\n    r = runner.invoke(cli_app, [\"generate\", \"flux-pro\", \"--prompt\", \"x\", \"--width\", \"1\", \"--height\", \"1\"])\n    assert r.exit_code == 0, r.stdout\n    assert \"https://cdn.example/result.png\" in r.stdout\n\n\ndef test_generate_async_returns_job_id(runner, api_key, monkeypatch):\n    submit = httpx.Response(200, json={\"id\": \"job-xyz\", \"polling_url\": \"https://x/poll\"})\n    monkeypatch.setattr(gen_app.client.httpx, \"post\", lambda *a, **kw: submit)\n    r = runner.invoke(\n        cli_app,\n        [\"generate\", \"flux-pro\", \"--prompt\", \"x\", \"--width\", \"1\", \"--height\", \"1\", \"--async\"],\n    )\n    assert r.exit_code == 0\n    assert \"Submitted\" in r.stdout\n    assert \"job-xyz\" in r.stdout\n    assert \"comfy generate resume\" in r.stdout\n\n\ndef test_generate_async_failure_status(runner, api_key, monkeypatch):\n    submit = httpx.Response(200, json={\"id\": \"job-xyz\", \"polling_url\": \"https://x/poll\"})\n    poll_fail = httpx.Response(200, json={\"status\": \"Content Moderated\", \"progress\": 0.0})\n    monkeypatch.setattr(gen_app.client.httpx, \"post\", lambda *a, **kw: submit)\n    monkeypatch.setattr(\"comfy_cli.command.generate.client.get\", lambda *a, **kw: poll_fail)\n    monkeypatch.setattr(\"comfy_cli.command.generate.poll._sleep\", lambda *_: None)\n    r = runner.invoke(cli_app, [\"generate\", \"flux-pro\", \"--prompt\", \"x\", \"--width\", \"1\", \"--height\", \"1\"])\n    assert r.exit_code == 1\n    assert \"failed\" in r.stdout.lower()\n\n\n# ─── generate: sync JSON response with URL outputs ──────────────────────\n\n\ndef test_generate_sync_prints_url(runner, api_key, monkeypatch):\n    resp = httpx.Response(200, json={\"data\": [{\"url\": \"https://cdn.example/a.png\"}]})\n    monkeypatch.setattr(gen_app.client.httpx, \"post\", lambda *a, **kw: resp)\n    r = runner.invoke(cli_app, [\"generate\", \"dalle\", \"--prompt\", \"x\"])\n    assert r.exit_code == 0, r.stdout\n    assert \"https://cdn.example/a.png\" in r.stdout\n\n\ndef test_generate_sync_with_download(runner, api_key, tmp_path, monkeypatch):\n    resp = httpx.Response(200, json={\"data\": [{\"url\": \"https://cdn.example/a.png\"}]})\n    monkeypatch.setattr(gen_app.client.httpx, \"post\", lambda *a, **kw: resp)\n    monkeypatch.setattr(\"comfy_cli.command.generate.client.download_bytes\", lambda *a, **kw: b\"png-bytes\")\n    download = str(tmp_path / \"out.png\")\n    r = runner.invoke(cli_app, [\"generate\", \"dalle\", \"--prompt\", \"x\", \"--download\", download])\n    assert r.exit_code == 0, r.stdout\n    assert Path(download).exists()\n    assert Path(download).read_bytes() == b\"png-bytes\"\n    assert \"Saved\" in r.stdout\n\n\ndef test_generate_json_flag(runner, api_key, monkeypatch):\n    resp = httpx.Response(200, json={\"data\": [{\"url\": \"https://cdn.example/a.png\"}]})\n    monkeypatch.setattr(gen_app.client.httpx, \"post\", lambda *a, **kw: resp)\n    r = runner.invoke(cli_app, [\"generate\", \"dalle\", \"--prompt\", \"x\", \"--json\"])\n    assert r.exit_code == 0\n    # Strip newlines/whitespace from output so we can match across rich's line wrapping\n    flat = \"\".join(r.stdout.split())\n    assert '\"url\":\"https://cdn.example/a.png\"' in flat\n\n\ndef test_generate_download_no_urls(runner, api_key, monkeypatch):\n    resp = httpx.Response(200, json={\"data\": []})\n    monkeypatch.setattr(gen_app.client.httpx, \"post\", lambda *a, **kw: resp)\n    r = runner.invoke(cli_app, [\"generate\", \"dalle\", \"--prompt\", \"x\", \"--download\", \"/tmp/x.png\"])\n    assert r.exit_code == 0\n    assert \"no image urls\" in r.stdout.lower()\n\n\n# ─── generate: sync binary response (Stability returns bytes) ────────────\n\n\ndef test_generate_binary_response_with_download(runner, api_key, tmp_path, monkeypatch):\n    resp = httpx.Response(200, content=b\"\\x89PNGfake\", headers={\"content-type\": \"image/png\"})\n    monkeypatch.setattr(gen_app.client.httpx, \"post\", lambda *a, **kw: resp)\n    download = str(tmp_path / \"ultra.png\")\n    r = runner.invoke(cli_app, [\"generate\", \"stability-ultra\", \"--prompt\", \"x\", \"--download\", download])\n    assert r.exit_code == 0, r.stdout\n    assert Path(download).exists()\n\n\ndef test_generate_binary_response_no_download(runner, api_key, monkeypatch):\n    resp = httpx.Response(200, content=b\"\\x89PNGfake\", headers={\"content-type\": \"image/png\"})\n    monkeypatch.setattr(gen_app.client.httpx, \"post\", lambda *a, **kw: resp)\n    r = runner.invoke(cli_app, [\"generate\", \"stability-ultra\", \"--prompt\", \"x\"])\n    assert r.exit_code == 0\n    assert \"nothing saved\" in r.stdout\n\n\n# ─── generate: HTTP and network errors ───────────────────────────────────\n\n\ndef test_generate_api_error_surface(runner, api_key, monkeypatch):\n    resp = httpx.Response(401, json={\"message\": \"Invalid token\"})\n    monkeypatch.setattr(gen_app.client.httpx, \"post\", lambda *a, **kw: resp)\n    r = runner.invoke(cli_app, [\"generate\", \"flux-pro\", \"--prompt\", \"x\", \"--width\", \"1\", \"--height\", \"1\"])\n    assert r.exit_code == 1\n    assert \"API error 401\" in r.stdout\n    assert \"Invalid token\" in r.stdout\n\n\ndef test_generate_network_error_surface(runner, api_key, monkeypatch):\n    def boom(*a, **kw):\n        raise httpx.ConnectError(\"connection refused\")\n\n    monkeypatch.setattr(gen_app.client.httpx, \"post\", boom)\n    r = runner.invoke(cli_app, [\"generate\", \"flux-pro\", \"--prompt\", \"x\", \"--width\", \"1\", \"--height\", \"1\"])\n    assert r.exit_code == 1\n    assert \"Network error\" in r.stdout\n\n\ndef test_generate_non_json_response(runner, api_key, monkeypatch):\n    resp = httpx.Response(200, text=\"not really json\", headers={\"content-type\": \"text/plain\"})\n    monkeypatch.setattr(gen_app.client.httpx, \"post\", lambda *a, **kw: resp)\n    r = runner.invoke(cli_app, [\"generate\", \"dalle\", \"--prompt\", \"x\"])\n    assert r.exit_code == 1\n    assert \"non-JSON\" in r.stdout\n\n\n# ─── resume ──────────────────────────────────────────────────────────────\n\n\ndef test_resume_missing_args(runner, api_key):\n    r = runner.invoke(cli_app, [\"generate\", \"resume\"])\n    assert r.exit_code == 1\n    assert \"Usage\" in r.stdout\n\n\ndef test_resume_sync_model_rejected(runner, api_key):\n    r = runner.invoke(cli_app, [\"generate\", \"resume\", \"dalle\", \"abc\"])\n    assert r.exit_code == 1\n    assert \"sync\" in r.stdout\n\n\ndef test_resume_unknown_model(runner, api_key):\n    r = runner.invoke(cli_app, [\"generate\", \"resume\", \"nope-model\", \"abc\"])\n    assert r.exit_code == 1\n    assert \"Unknown model\" in r.stdout\n\n\ndef test_resume_async_succeeds(runner, api_key, monkeypatch):\n    poll_done = httpx.Response(\n        200,\n        json={\"status\": \"Ready\", \"progress\": 1.0, \"result\": {\"sample\": \"https://cdn.example/done.png\"}},\n    )\n    monkeypatch.setattr(\"comfy_cli.command.generate.client.get\", lambda *a, **kw: poll_done)\n    monkeypatch.setattr(\"comfy_cli.command.generate.poll._sleep\", lambda *_: None)\n    r = runner.invoke(cli_app, [\"generate\", \"resume\", \"flux-pro\", \"job-123\"])\n    assert r.exit_code == 0\n    assert \"https://cdn.example/done.png\" in r.stdout\n\n\ndef test_resume_with_download(runner, api_key, tmp_path, monkeypatch):\n    poll_done = httpx.Response(\n        200,\n        json={\"status\": \"Ready\", \"progress\": 1.0, \"result\": {\"sample\": \"https://cdn.example/done.png\"}},\n    )\n    monkeypatch.setattr(\"comfy_cli.command.generate.client.get\", lambda *a, **kw: poll_done)\n    monkeypatch.setattr(\"comfy_cli.command.generate.client.download_bytes\", lambda *a, **kw: b\"bytes\")\n    monkeypatch.setattr(\"comfy_cli.command.generate.poll._sleep\", lambda *_: None)\n    download = str(tmp_path / \"resumed.png\")\n    r = runner.invoke(cli_app, [\"generate\", \"resume\", \"flux-pro\", \"job-123\", \"--download\", download])\n    assert r.exit_code == 0\n    assert Path(download).exists()\n\n\n# ─── refresh ─────────────────────────────────────────────────────────────\n\n\ndef test_refresh_writes_cache(runner, monkeypatch, tmp_path):\n    captured = {}\n\n    class FakeClient:\n        def __init__(self, *a, **kw):\n            pass\n\n        def __enter__(self):\n            return self\n\n        def __exit__(self, *a):\n            pass\n\n        def get(self, url, headers=None):\n            captured[\"url\"] = url\n            captured[\"headers\"] = headers or {}\n            return httpx.Response(\n                200,\n                text=\"openapi: 3.0.0\\n\",\n                request=httpx.Request(\"GET\", url),\n            )\n\n    monkeypatch.setattr(gen_app.httpx, \"Client\", FakeClient)\n    monkeypatch.setattr(\"comfy_cli.command.generate.spec._USER_CACHE\", tmp_path / \"openapi-cache.yml\")\n\n    r = runner.invoke(cli_app, [\"generate\", \"refresh\"])\n    assert r.exit_code == 0, r.stdout\n    assert \"Refreshed\" in r.stdout\n    assert (tmp_path / \"openapi-cache.yml\").exists()\n    assert captured[\"headers\"].get(\"Comfy-Env\") == \"comfy-cli\"\n\n\ndef test_refresh_network_failure(runner, monkeypatch):\n    class FakeClient:\n        def __init__(self, *a, **kw):\n            pass\n\n        def __enter__(self):\n            return self\n\n        def __exit__(self, *a):\n            pass\n\n        def get(self, *a, **kw):\n            raise httpx.ConnectError(\"no net\")\n\n    monkeypatch.setattr(gen_app.httpx, \"Client\", FakeClient)\n    r = runner.invoke(cli_app, [\"generate\", \"refresh\"])\n    assert r.exit_code == 1\n    assert \"Failed to fetch\" in r.stdout\n\n\n# ─── upload subcommand ──────────────────────────────────────────────────\n\n\ndef test_upload_missing_arg(runner, api_key):\n    r = runner.invoke(cli_app, [\"generate\", \"upload\"])\n    assert r.exit_code == 1\n    assert \"Usage\" in r.stdout\n\n\ndef test_upload_local_file(runner, api_key, tmp_path, monkeypatch):\n    img = tmp_path / \"x.png\"\n    img.write_bytes(b\"png-data\")\n    monkeypatch.setattr(\n        \"comfy_cli.command.generate.upload.upload_target\",\n        lambda target, api_key: gen_app.upload.UploadResult(\n            url=\"https://cdn/x.png\", expires_at=\"2099-01-01T00:00:00Z\", existing_file=False\n        ),\n    )\n    r = runner.invoke(cli_app, [\"generate\", \"upload\", str(img)])\n    assert r.exit_code == 0, r.stdout\n    assert \"Uploaded\" in r.stdout\n    assert \"https://cdn/x.png\" in r.stdout\n\n\ndef test_upload_json_output(runner, api_key, tmp_path, monkeypatch):\n    img = tmp_path / \"x.png\"\n    img.write_bytes(b\"png-data\")\n    monkeypatch.setattr(\n        \"comfy_cli.command.generate.upload.upload_target\",\n        lambda target, api_key: gen_app.upload.UploadResult(\n            url=\"https://cdn/x.png\", expires_at=\"2099-01-01T00:00:00Z\", existing_file=True\n        ),\n    )\n    r = runner.invoke(cli_app, [\"generate\", \"upload\", str(img), \"--json\"])\n    assert r.exit_code == 0\n    flat = \"\".join(r.stdout.split())\n    assert '\"url\":\"https://cdn/x.png\"' in flat\n    assert '\"existing_file\":true' in flat\n\n\ndef test_upload_does_not_mistake_meta_value_for_target(runner, monkeypatch, tmp_path):\n    \"\"\"`upload --api-key KEY ./img.png` must resolve ./img.png as the target,\n    not KEY — regression check for the positional parsing bug.\"\"\"\n    img = tmp_path / \"x.png\"\n    img.write_bytes(b\"png-data\")\n    captured = {}\n\n    def fake_upload(target, api_key):\n        captured[\"target\"] = target\n        captured[\"api_key\"] = api_key\n        return gen_app.upload.UploadResult(url=\"https://cdn/x.png\", expires_at=None, existing_file=False)\n\n    monkeypatch.setattr(\"comfy_cli.command.generate.upload.upload_target\", fake_upload)\n    r = runner.invoke(cli_app, [\"generate\", \"upload\", \"--api-key\", \"comfyui-test\", str(img)])\n    assert r.exit_code == 0, r.stdout\n    assert captured[\"target\"] == str(img)\n    assert captured[\"api_key\"] == \"comfyui-test\"\n\n\ndef test_upload_propagates_api_error(runner, api_key, tmp_path, monkeypatch):\n    img = tmp_path / \"x.png\"\n    img.write_bytes(b\"png-data\")\n\n    def boom(*a, **kw):\n        raise gen_app.client.ApiError(500, \"fail\", \"boom\")\n\n    monkeypatch.setattr(\"comfy_cli.command.generate.upload.upload_target\", boom)\n    r = runner.invoke(cli_app, [\"generate\", \"upload\", str(img)])\n    assert r.exit_code == 1\n    assert \"Upload failed\" in r.stdout\n\n\n# ─── auto-upload during generate ────────────────────────────────────────\n\n\ndef test_generate_auto_base64_for_kontext(runner, api_key, tmp_path, monkeypatch):\n    \"\"\"flux-kontext's input_image expects a Base64 string — local files should\n    be auto-encoded with no extra steps.\"\"\"\n    img = tmp_path / \"ref.png\"\n    img.write_bytes(b\"\\x89PNGfake\")\n\n    captured = {}\n\n    def fake_post(url, *, json=None, headers=None, timeout=None, **_):\n        captured[\"body\"] = json\n        return httpx.Response(200, json={\"id\": \"job-xyz\", \"polling_url\": \"https://x/poll\"})\n\n    monkeypatch.setattr(gen_app.client.httpx, \"post\", fake_post)\n    r = runner.invoke(\n        cli_app,\n        [\"generate\", \"flux-kontext\", \"--prompt\", \"edit it\", \"--input_image\", str(img), \"--async\"],\n    )\n    assert r.exit_code == 0, r.stdout\n    assert captured[\"body\"][\"input_image\"] == base64.b64encode(b\"\\x89PNGfake\").decode(\"ascii\")\n\n\ndef test_generate_auto_upload_leaves_url_alone(runner, api_key, monkeypatch):\n    \"\"\"A pre-existing https:// URL must NOT trigger an upload.\"\"\"\n    upload_called = {\"hit\": False}\n\n    def fake_upload(*a, **kw):\n        upload_called[\"hit\"] = True\n        return gen_app.upload.UploadResult(url=\"x\", expires_at=None, existing_file=False)\n\n    monkeypatch.setattr(\"comfy_cli.command.generate.upload.upload_path\", fake_upload)\n    captured = {}\n\n    def fake_post(url, *, json=None, headers=None, timeout=None, **_):\n        captured[\"body\"] = json\n        return httpx.Response(200, json={\"id\": \"x\", \"polling_url\": \"https://x\"})\n\n    monkeypatch.setattr(gen_app.client.httpx, \"post\", fake_post)\n    r = runner.invoke(\n        cli_app,\n        [\n            \"generate\",\n            \"flux-kontext\",\n            \"--prompt\",\n            \"x\",\n            \"--input_image\",\n            \"https://existing/url.png\",\n            \"--async\",\n        ],\n    )\n    assert r.exit_code == 0\n    assert upload_called[\"hit\"] is False\n    assert captured[\"body\"][\"input_image\"] == \"https://existing/url.png\"\n\n\ndef test_generate_auto_upload_skipped_for_multipart(runner, api_key, tmp_path, monkeypatch):\n    \"\"\"Multipart endpoints (ideogram-edit) already stream files via httpx —\n    they must not be funneled through /customers/storage.\"\"\"\n    img = tmp_path / \"x.png\"\n    img.write_bytes(b\"png\")\n\n    upload_called = {\"hit\": False}\n    monkeypatch.setattr(\n        \"comfy_cli.command.generate.upload.upload_path\",\n        lambda *a, **kw: upload_called.__setitem__(\"hit\", True) or gen_app.upload.UploadResult(\"x\", None, False),\n    )\n    monkeypatch.setattr(\n        gen_app.client.httpx,\n        \"post\",\n        lambda *a, **kw: httpx.Response(200, json={\"data\": [{\"url\": \"https://x/a.png\"}]}),\n    )\n    r = runner.invoke(\n        cli_app,\n        [\n            \"generate\",\n            \"ideogram-edit\",\n            \"--prompt\",\n            \"x\",\n            \"--rendering_speed\",\n            \"TURBO\",\n            \"--image\",\n            str(img),\n        ],\n    )\n    assert r.exit_code == 0\n    assert upload_called[\"hit\"] is False\n\n\n# ─── video models (async polling, generic poller path) ─────────────────\n\n\ndef test_video_kling_async_path(runner, api_key, monkeypatch):\n    \"\"\"End-to-end async path through the generic kling poller.\"\"\"\n    submit = httpx.Response(200, json={\"data\": {\"task_id\": \"k-xyz\"}})\n    finished = httpx.Response(\n        200,\n        json={\n            \"data\": {\n                \"task_status\": \"succeed\",\n                \"task_result\": {\"videos\": [{\"url\": \"https://cdn.example/k.mp4\"}]},\n            }\n        },\n    )\n    monkeypatch.setattr(gen_app.client.httpx, \"post\", lambda *a, **kw: submit)\n    monkeypatch.setattr(\"comfy_cli.command.generate.client.get\", lambda *a, **kw: finished)\n    monkeypatch.setattr(\"comfy_cli.command.generate.poll._sleep\", lambda *_: None)\n\n    r = runner.invoke(cli_app, [\"generate\", \"kling\", \"--prompt\", \"a cat\", \"--duration\", \"5\"])\n    assert r.exit_code == 0, r.stdout\n    assert \"https://cdn.example/k.mp4\" in r.stdout\n\n\ndef test_video_luma_async_path(runner, api_key, monkeypatch):\n    submit = httpx.Response(200, json={\"id\": \"luma-1\", \"state\": \"queued\"})\n    done = httpx.Response(200, json={\"id\": \"luma-1\", \"state\": \"completed\", \"assets\": {\"video\": \"https://cdn/l.mp4\"}})\n    monkeypatch.setattr(gen_app.client.httpx, \"post\", lambda *a, **kw: submit)\n    monkeypatch.setattr(\"comfy_cli.command.generate.client.get\", lambda *a, **kw: done)\n    monkeypatch.setattr(\"comfy_cli.command.generate.poll._sleep\", lambda *_: None)\n\n    r = runner.invoke(\n        cli_app,\n        [\n            \"generate\",\n            \"luma\",\n            \"--prompt\",\n            \"a cat\",\n            \"--aspect_ratio\",\n            \"16:9\",\n            \"--model\",\n            \"ray-2\",\n            \"--resolution\",\n            \"{}\",\n            \"--duration\",\n            \"{}\",\n        ],\n    )\n    assert r.exit_code == 0, r.stdout\n    assert \"https://cdn/l.mp4\" in r.stdout\n\n\ndef test_video_runway_failure_surfaces(runner, api_key, monkeypatch):\n    submit = httpx.Response(200, json={\"id\": \"rw-1\"})\n    fail = httpx.Response(200, json={\"id\": \"rw-1\", \"status\": \"FAILED\"})\n    monkeypatch.setattr(gen_app.client.httpx, \"post\", lambda *a, **kw: submit)\n    monkeypatch.setattr(\"comfy_cli.command.generate.client.get\", lambda *a, **kw: fail)\n    monkeypatch.setattr(\"comfy_cli.command.generate.poll._sleep\", lambda *_: None)\n\n    r = runner.invoke(\n        cli_app,\n        [\n            \"generate\",\n            \"runway-i2v\",\n            \"--promptImage\",\n            '\"https://x/img.png\"',\n            \"--seed\",\n            \"1\",\n            \"--model\",\n            \"gen4_turbo\",\n            \"--duration\",\n            \"5\",\n            \"--ratio\",\n            \"1280:720\",\n        ],\n    )\n    assert r.exit_code == 1\n    assert \"FAILED\" in r.stdout\n\n\ndef test_video_async_submission_shows_resume_alias(runner, api_key, monkeypatch):\n    submit = httpx.Response(200, json={\"data\": {\"task_id\": \"k-async-1\"}})\n    monkeypatch.setattr(gen_app.client.httpx, \"post\", lambda *a, **kw: submit)\n    r = runner.invoke(cli_app, [\"generate\", \"kling\", \"--prompt\", \"x\", \"--async\"])\n    assert r.exit_code == 0, r.stdout\n    assert \"k-async-1\" in r.stdout\n    assert \"comfy generate resume kling k-async-1\" in r.stdout\n\n\ndef test_video_resume_kling(runner, api_key, monkeypatch):\n    done = httpx.Response(\n        200,\n        json={\n            \"data\": {\n                \"task_status\": \"succeed\",\n                \"task_result\": {\"videos\": [{\"url\": \"https://cdn/resumed.mp4\"}]},\n            }\n        },\n    )\n    monkeypatch.setattr(\"comfy_cli.command.generate.client.get\", lambda *a, **kw: done)\n    monkeypatch.setattr(\"comfy_cli.command.generate.poll._sleep\", lambda *_: None)\n    r = runner.invoke(cli_app, [\"generate\", \"resume\", \"kling\", \"k-async-1\"])\n    assert r.exit_code == 0, r.stdout\n    assert \"https://cdn/resumed.mp4\" in r.stdout\n\n\ndef test_list_video_filter(runner):\n    r = runner.invoke(cli_app, [\"generate\", \"list\", \"--style\", \"text-to-video\"])\n    assert r.exit_code == 0\n    assert \"kling\" in r.stdout\n    assert \"luma\" in r.stdout\n    assert \"pika\" in r.stdout\n\n\n# ─── helpers: _arg_value / _separate_meta_flags ──────────────────────────\n\n\ndef test_arg_value_long_and_eq():\n    assert gen_app._arg_value([\"--foo\", \"bar\"], \"--foo\") == \"bar\"\n    assert gen_app._arg_value([\"--foo=baz\"], \"--foo\") == \"baz\"\n    assert gen_app._arg_value([\"--bar\", \"v\"], \"--foo\", \"-f\") is None\n\n\ndef test_arg_value_alternatives():\n    assert gen_app._arg_value([\"-p\", \"bfl\"], \"--partner\", \"-p\") == \"bfl\"\n\n\ndef test_separate_meta_flags_typical():\n    rest, meta = gen_app._separate_meta_flags([\"--prompt\", \"x\", \"--download\", \"out.png\", \"--async\", \"--timeout\", \"30\"])\n    assert rest == [\"--prompt\", \"x\"]\n    assert meta[\"download\"] == \"out.png\"\n    assert meta[\"async\"] is True\n    assert meta[\"timeout\"] == \"30\"\n\n\ndef test_separate_meta_flags_eq_form():\n    _, meta = gen_app._separate_meta_flags([\"--download=cat.png\", \"--json\"])\n    assert meta == {\"download\": \"cat.png\", \"json\": True}\n\n\ndef test_separate_meta_flags_missing_value_raises():\n    from comfy_cli.command.generate.schema import SchemaError\n\n    with pytest.raises(SchemaError):\n        gen_app._separate_meta_flags([\"--download\"])\n"
  },
  {
    "path": "tests/comfy_cli/command/generate/test_client.py",
    "content": "\"\"\"Tests for the httpx client wrapper — auth header, payload split.\"\"\"\n\nimport httpx\nimport pytest\n\nfrom comfy_cli.command.generate import client, schema, spec\n\n\ndef test_resolve_api_key_from_env(monkeypatch):\n    monkeypatch.setenv(\"COMFY_API_KEY\", \"  sk-abc  \")\n    assert client.resolve_api_key() == \"sk-abc\"\n\n\ndef test_resolve_api_key_explicit_wins(monkeypatch):\n    monkeypatch.setenv(\"COMFY_API_KEY\", \"env-key\")\n    assert client.resolve_api_key(\"flag-key\") == \"flag-key\"\n\n\ndef test_resolve_api_key_missing(monkeypatch):\n    monkeypatch.delenv(\"COMFY_API_KEY\", raising=False)\n    with pytest.raises(client.ApiError, match=\"No API key\"):\n        client.resolve_api_key()\n\n\ndef test_split_payload_json_pass_through():\n    ep = spec.get_endpoint(\"bfl/flux-pro-1.1/generate\")\n    flags = schema.flags_for(ep)\n    json_body, files, data = client._split_payload(\n        {\"prompt\": \"x\", \"width\": 1024, \"height\": 1024},\n        flags,\n        ep.request_content_type,\n    )\n    assert json_body == {\"prompt\": \"x\", \"width\": 1024, \"height\": 1024}\n    assert files is None and data is None\n\n\ndef test_split_payload_multipart_separates_files(tmp_path):\n    img = tmp_path / \"img.png\"\n    img.write_bytes(b\"fake\")\n    ep = spec.get_endpoint(\"ideogram/ideogram-v3/edit\")\n    flags = schema.flags_for(ep)\n    json_body, files, data = client._split_payload(\n        {\"prompt\": \"edit\", \"rendering_speed\": \"TURBO\", \"image\": img, \"num_images\": 2},\n        flags,\n        ep.request_content_type,\n    )\n    assert json_body is None\n    field_names = [name for name, _ in files]\n    assert \"image\" in field_names\n    assert data[\"prompt\"] == \"edit\"\n    assert data[\"num_images\"] == \"2\"\n    # Close any file handles we opened.\n    for _name, payload in files:\n        payload[1].close()\n\n\ndef _capture_post(monkeypatch):\n    captured = {}\n\n    def fake_post(url, *, json=None, headers=None, timeout=None, **_kw):\n        captured[\"url\"] = url\n        captured[\"headers\"] = headers\n        captured[\"json\"] = json\n        return httpx.Response(200, json={\"id\": \"abc\", \"polling_url\": \"https://x\"})\n\n    monkeypatch.setattr(client.httpx, \"post\", fake_post)\n    return captured\n\n\ndef test_send_request_uses_x_api_key_for_comfyui_keys(monkeypatch):\n    ep = spec.get_endpoint(\"bfl/flux-pro-1.1/generate\")\n    flags = schema.flags_for(ep)\n    captured = _capture_post(monkeypatch)\n    client.send_request(ep, {\"prompt\": \"x\", \"width\": 1, \"height\": 1}, flags, api_key=\"comfyui-abc\")\n    assert captured[\"headers\"][\"X-API-Key\"] == \"comfyui-abc\"\n    assert \"Authorization\" not in captured[\"headers\"]\n    assert captured[\"headers\"][\"Comfy-Env\"] == \"comfy-cli\"\n\n\ndef test_send_request_uses_bearer_for_firebase_tokens(monkeypatch):\n    ep = spec.get_endpoint(\"bfl/flux-pro-1.1/generate\")\n    flags = schema.flags_for(ep)\n    captured = _capture_post(monkeypatch)\n    client.send_request(ep, {\"prompt\": \"x\", \"width\": 1, \"height\": 1}, flags, api_key=\"eyJhbGciOi.foo.bar\")\n    assert captured[\"headers\"][\"Authorization\"] == \"Bearer eyJhbGciOi.foo.bar\"\n    assert \"X-API-Key\" not in captured[\"headers\"]\n    assert captured[\"url\"].endswith(\"/proxy/bfl/flux-pro-1.1/generate\")\n\n\ndef test_raise_for_status_includes_body():\n    resp = httpx.Response(400, json={\"error\": \"bad prompt\"})\n    with pytest.raises(client.ApiError) as exc:\n        client.raise_for_status(resp)\n    assert exc.value.status == 400\n    assert \"bad prompt\" in exc.value.body\n"
  },
  {
    "path": "tests/comfy_cli/command/generate/test_output.py",
    "content": "\"\"\"Tests for download templating.\"\"\"\n\nimport httpx\n\nfrom comfy_cli.command.generate import output\n\n\ndef test_resolve_template_directory_shorthand(tmp_path):\n    p = output._resolve_template(f\"{tmp_path}/\", \"abc123\", 0, \"png\")\n    assert p == tmp_path / \"abc123_0.png\"\n\n\ndef test_resolve_template_placeholders(tmp_path):\n    tmpl = str(tmp_path / \"out_{request_id}_{index}.{ext}\")\n    p = output._resolve_template(tmpl, \"abc\", 2, \"jpg\")\n    assert p == tmp_path / \"out_abc_2.jpg\"\n\n\ndef test_ext_from_response_known_mime():\n    r = httpx.Response(200, headers={\"content-type\": \"image/jpeg\"})\n    assert output._ext_from_response(r) == \"jpg\"\n\n\ndef test_ext_from_url_strips_query():\n    assert output._ext_from_url(\"https://x/result.webp?sig=abc\") == \"webp\"\n"
  },
  {
    "path": "tests/comfy_cli/command/generate/test_poll.py",
    "content": "\"\"\"Tests for the BFL polling adapter.\"\"\"\n\nfrom unittest.mock import patch\n\nimport httpx\n\nfrom comfy_cli.command.generate import poll\n\n\ndef _resp(body):\n    return httpx.Response(200, json=body)\n\n\ndef test_poll_bfl_extracts_sample_url():\n    responses = iter(\n        [\n            _resp({\"id\": \"abc\", \"status\": \"Pending\", \"progress\": 0.2}),\n            _resp(\n                {\n                    \"id\": \"abc\",\n                    \"status\": \"Ready\",\n                    \"progress\": 1.0,\n                    \"result\": {\"sample\": \"https://cdn.example/result.png\"},\n                }\n            ),\n        ]\n    )\n\n    progress_seen: list[float] = []\n\n    with (\n        patch(\"comfy_cli.command.generate.client.get\", side_effect=lambda *a, **kw: next(responses)),\n        patch(\"comfy_cli.command.generate.poll._sleep\", lambda *_: None),\n    ):\n        result = poll.poll_bfl(\n            {\"polling_url\": \"https://api.comfy.org/proxy/bfl/get_result?id=abc\"},\n            api_key=\"sk-test\",\n            on_progress=progress_seen.append,\n        )\n\n    assert result.status == \"succeeded\"\n    assert result.image_urls == [\"https://cdn.example/result.png\"]\n    assert progress_seen == [0.2, 1.0]\n\n\ndef test_poll_bfl_reports_failure():\n    responses = iter([_resp({\"id\": \"abc\", \"status\": \"Content Moderated\", \"progress\": 0.0})])\n    with (\n        patch(\"comfy_cli.command.generate.client.get\", side_effect=lambda *a, **kw: next(responses)),\n        patch(\"comfy_cli.command.generate.poll._sleep\", lambda *_: None),\n    ):\n        result = poll.poll_bfl(\n            {\"polling_url\": \"https://x\"},\n            api_key=\"sk-test\",\n        )\n    assert result.status == \"failed\"\n    assert \"Content Moderated\" in (result.error or \"\")\n"
  },
  {
    "path": "tests/comfy_cli/command/generate/test_schema.py",
    "content": "\"\"\"Tests for openapi schema → CLI flag conversion and argv parsing.\"\"\"\n\nimport pytest\n\nfrom comfy_cli.command.generate import schema, spec\n\n\ndef test_flags_for_bfl_classifies_types():\n    ep = spec.get_endpoint(\"bfl/flux-pro-1.1/generate\")\n    flags = {f.name: f for f in schema.flags_for(ep)}\n    assert flags[\"prompt\"].kind == \"string\"\n    assert flags[\"prompt\"].required\n    assert flags[\"width\"].kind == \"integer\"\n    assert flags[\"prompt_upsampling\"].kind == \"boolean\"\n    assert flags[\"output_format\"].kind == \"enum\"\n    assert flags[\"output_format\"].enum == [\"jpeg\", \"png\"]\n\n\ndef test_flags_for_multipart_finds_binary_fields():\n    ep = spec.get_endpoint(\"ideogram/ideogram-v3/edit\")\n    flags = {f.name: f for f in schema.flags_for(ep)}\n    assert flags[\"image\"].kind == \"binary\"\n    # style_reference_images is an array of binary file inputs.\n    assert flags[\"style_reference_images\"].kind == \"array\"\n    assert flags[\"style_reference_images\"].item_kind == \"binary\"\n\n\ndef test_parse_args_basic_coercion():\n    ep = spec.get_endpoint(\"bfl/flux-pro-1.1/generate\")\n    flags = schema.flags_for(ep)\n    values = schema.parse_args(\n        flags,\n        [\"--prompt\", \"a cat\", \"--width\", \"1024\", \"--height\", \"1024\", \"--prompt_upsampling\"],\n    )\n    assert values == {\n        \"prompt\": \"a cat\",\n        \"width\": 1024,\n        \"height\": 1024,\n        \"prompt_upsampling\": True,\n    }\n\n\ndef test_parse_args_eq_form_and_enum():\n    ep = spec.get_endpoint(\"bfl/flux-pro-1.1/generate\")\n    flags = schema.flags_for(ep)\n    values = schema.parse_args(\n        flags,\n        [\"--prompt=a\", \"--width=1\", \"--height=1\", \"--output_format=png\"],\n    )\n    assert values[\"output_format\"] == \"png\"\n\n\ndef test_parse_args_rejects_unknown_flag():\n    ep = spec.get_endpoint(\"bfl/flux-pro-1.1/generate\")\n    flags = schema.flags_for(ep)\n    with pytest.raises(schema.SchemaError, match=\"Unknown flag\"):\n        schema.parse_args(flags, [\"--prompt\", \"a\", \"--width\", \"1\", \"--height\", \"1\", \"--bogus\", \"x\"])\n\n\ndef test_parse_args_rejects_bad_int():\n    ep = spec.get_endpoint(\"bfl/flux-pro-1.1/generate\")\n    flags = schema.flags_for(ep)\n    with pytest.raises(schema.SchemaError, match=\"expected integer\"):\n        schema.parse_args(flags, [\"--prompt\", \"a\", \"--width\", \"abc\", \"--height\", \"1\"])\n\n\ndef test_parse_args_missing_required():\n    ep = spec.get_endpoint(\"bfl/flux-pro-1.1/generate\")\n    flags = schema.flags_for(ep)\n    with pytest.raises(schema.SchemaError, match=\"Missing required\"):\n        schema.parse_args(flags, [\"--prompt\", \"a\"])\n\n\ndef test_parse_args_enum_value_validated():\n    ep = spec.get_endpoint(\"bfl/flux-pro-1.1/generate\")\n    flags = schema.flags_for(ep)\n    with pytest.raises(schema.SchemaError, match=\"not one of\"):\n        schema.parse_args(\n            flags,\n            [\"--prompt\", \"a\", \"--width\", \"1\", \"--height\", \"1\", \"--output_format\", \"tiff\"],\n        )\n\n\ndef test_parse_args_object_accepts_json():\n    ep = spec.get_endpoint(\"ideogram/ideogram-v3/generate\")\n    flags = schema.flags_for(ep)\n    values = schema.parse_args(\n        flags,\n        [\n            \"--prompt\",\n            \"x\",\n            \"--rendering_speed\",\n            \"TURBO\",\n            \"--color_palette\",\n            '{\"name\":\"PASTEL\"}',\n        ],\n    )\n    assert values[\"color_palette\"] == {\"name\": \"PASTEL\"}\n"
  },
  {
    "path": "tests/comfy_cli/command/generate/test_spec.py",
    "content": "\"\"\"Tests for the openapi registry — verify the curated image allowlist resolves\nagainst the vendored spec and classifies each endpoint correctly.\"\"\"\n\nfrom comfy_cli.command.generate import spec\n\n\ndef test_registry_loads_and_has_entries():\n    eps = spec.list_endpoints()\n    assert len(eps) > 20, \"expected the v1 allowlist to resolve >20 endpoints\"\n\n\ndef test_get_endpoint_round_trip():\n    ep = spec.get_endpoint(\"bfl/flux-pro-1.1/generate\")\n    assert ep.partner == \"bfl\"\n    assert ep.path == \"/proxy/bfl/flux-pro-1.1/generate\"\n    assert ep.method == \"post\"\n    assert ep.polling == \"bfl\"\n    assert ep.category == \"text-to-image\"\n\n\ndef test_unknown_endpoint_suggests_close_match():\n    try:\n        spec.get_endpoint(\"bfl/flux-pro-1.1/genrate\")  # typo\n    except spec.SpecError as e:\n        assert \"Did you mean\" in str(e)\n        assert \"bfl/flux-pro-1.1/generate\" in str(e)\n    else:\n        raise AssertionError(\"expected SpecError\")\n\n\ndef test_request_schema_resolved_no_refs():\n    ep = spec.get_endpoint(\"ideogram/ideogram-v3/generate\")\n    props = ep.request_schema[\"properties\"]\n    # `rendering_speed` was a $ref in source; should now be inlined.\n    assert isinstance(props[\"rendering_speed\"], dict)\n    assert \"$ref\" not in props[\"rendering_speed\"]\n\n\ndef test_multipart_endpoints_detected():\n    ep = spec.get_endpoint(\"ideogram/ideogram-v3/edit\")\n    assert ep.request_content_type == \"multipart/form-data\"\n\n\ndef test_json_endpoints_detected():\n    ep = spec.get_endpoint(\"bfl/flux-pro-1.1/generate\")\n    assert ep.request_content_type == \"application/json\"\n\n\ndef test_sync_endpoints_have_no_polling():\n    ep = spec.get_endpoint(\"openai/images/generations\")\n    assert ep.polling is None\n\n\ndef test_filter_by_partner_and_category():\n    bfl = spec.list_endpoints(partner=\"bfl\")\n    assert bfl and all(e.partner == \"bfl\" for e in bfl)\n    t2i = spec.list_endpoints(category=\"text-to-image\")\n    assert all(e.category == \"text-to-image\" for e in t2i)\n\n\ndef test_proxy_prefix_accepted():\n    ep = spec.get_endpoint(\"/proxy/bfl/flux-pro-1.1/generate\")\n    assert ep.id == \"bfl/flux-pro-1.1/generate\"\n"
  },
  {
    "path": "tests/comfy_cli/command/generate/test_upload.py",
    "content": "\"\"\"Tests for /customers/storage upload helpers.\"\"\"\n\nimport httpx\nimport pytest\n\nfrom comfy_cli.command.generate import client, upload\n\n\ndef test_request_signed_url_posts_hash(monkeypatch):\n    captured = {}\n\n    def fake_post(url, *, json=None, headers=None, timeout=None):\n        captured[\"url\"] = url\n        captured[\"json\"] = json\n        captured[\"headers\"] = headers\n        return httpx.Response(\n            200,\n            json={\n                \"upload_url\": \"https://signed/up\",\n                \"download_url\": \"https://signed/down\",\n                \"expires_at\": \"2099-01-01T00:00:00Z\",\n                \"existing_file\": False,\n            },\n        )\n\n    monkeypatch.setattr(upload.httpx, \"post\", fake_post)\n    body = upload._request_signed_url(\"cat.png\", \"image/png\", \"deadbeef\", \"comfyui-test\")\n    assert body[\"upload_url\"] == \"https://signed/up\"\n    assert captured[\"json\"] == {\"file_name\": \"cat.png\", \"content_type\": \"image/png\", \"file_hash\": \"deadbeef\"}\n    assert captured[\"headers\"][\"X-API-Key\"] == \"comfyui-test\"\n    assert captured[\"headers\"][\"Comfy-Env\"] == \"comfy-cli\"\n    assert captured[\"url\"].endswith(\"/customers/storage\")\n\n\ndef test_upload_bytes_dedupe_skips_put(monkeypatch):\n    monkeypatch.setattr(\n        upload,\n        \"_request_signed_url\",\n        lambda **kw: {\n            \"download_url\": \"https://cached/x.png\",\n            \"existing_file\": True,\n            \"expires_at\": \"2099-01-01T00:00:00Z\",\n        },\n    )\n    called = {\"put\": False}\n\n    def boom(*a, **kw):\n        called[\"put\"] = True\n\n    monkeypatch.setattr(upload, \"_put_bytes\", boom)\n    result = upload.upload_bytes(b\"hello\", \"x.png\", \"comfyui-test\")\n    assert result.url == \"https://cached/x.png\"\n    assert result.existing_file is True\n    assert called[\"put\"] is False\n\n\ndef test_upload_bytes_new_file_puts(monkeypatch):\n    monkeypatch.setattr(\n        upload,\n        \"_request_signed_url\",\n        lambda **kw: {\n            \"upload_url\": \"https://signed/up\",\n            \"download_url\": \"https://signed/down\",\n            \"existing_file\": False,\n            \"expires_at\": None,\n        },\n    )\n    captured = {}\n\n    def fake_put(upload_url, data, content_type):\n        captured[\"upload_url\"] = upload_url\n        captured[\"data\"] = data\n        captured[\"content_type\"] = content_type\n\n    monkeypatch.setattr(upload, \"_put_bytes\", fake_put)\n    result = upload.upload_bytes(b\"raw-bytes\", \"cat.png\", \"comfyui-test\")\n    assert result.url == \"https://signed/down\"\n    assert result.existing_file is False\n    assert captured[\"data\"] == b\"raw-bytes\"\n    assert captured[\"content_type\"] == \"image/png\"\n\n\ndef test_upload_path_reads_file(monkeypatch, tmp_path):\n    img = tmp_path / \"x.jpg\"\n    img.write_bytes(b\"jpeg-bytes\")\n    called = {}\n\n    def fake_upload_bytes(data, file_name, api_key, content_type=None):\n        called[\"data\"] = data\n        called[\"file_name\"] = file_name\n        called[\"content_type\"] = content_type\n        return upload.UploadResult(url=\"https://x/a\", expires_at=None, existing_file=False)\n\n    monkeypatch.setattr(upload, \"upload_bytes\", fake_upload_bytes)\n    upload.upload_path(img, \"comfyui-test\")\n    assert called[\"data\"] == b\"jpeg-bytes\"\n    assert called[\"file_name\"] == \"x.jpg\"\n\n\ndef test_upload_path_missing_file(tmp_path):\n    with pytest.raises(client.ApiError, match=\"not found\"):\n        upload.upload_path(tmp_path / \"nope.png\", \"comfyui-test\")\n\n\ndef test_upload_remote_url_rehosts(monkeypatch):\n    monkeypatch.setattr(\n        upload,\n        \"upload_bytes\",\n        lambda data, file_name, api_key, content_type=None: upload.UploadResult(\n            url=f\"https://rehosted/{file_name}\", expires_at=None, existing_file=False\n        ),\n    )\n\n    class FakeClient:\n        def __init__(self, *a, **kw):\n            pass\n\n        def __enter__(self):\n            return self\n\n        def __exit__(self, *a):\n            pass\n\n        def get(self, url):\n            return httpx.Response(\n                200,\n                content=b\"png-bytes\",\n                headers={\"content-type\": \"image/png\"},\n                request=httpx.Request(\"GET\", url),\n            )\n\n    monkeypatch.setattr(upload.httpx, \"Client\", FakeClient)\n    result = upload.upload_remote_url(\"https://example.com/photo.png\", \"comfyui-test\")\n    assert result.url == \"https://rehosted/photo.png\"\n\n\ndef test_upload_target_dispatches_on_scheme(monkeypatch, tmp_path):\n    called = {}\n    monkeypatch.setattr(upload, \"upload_path\", lambda p, api_key: called.setdefault(\"path\", p))\n    monkeypatch.setattr(upload, \"upload_remote_url\", lambda u, api_key: called.setdefault(\"url\", u))\n    upload.upload_target(\"https://example.com/x.png\", \"k\")\n    upload.upload_target(\"/tmp/x.png\", \"k\")\n    assert called[\"url\"] == \"https://example.com/x.png\"\n    assert called[\"path\"] == \"/tmp/x.png\"\n\n\ndef test_put_bytes_raises_on_error(monkeypatch):\n    class FakeClient:\n        def __init__(self, *a, **kw):\n            pass\n\n        def __enter__(self):\n            return self\n\n        def __exit__(self, *a):\n            pass\n\n        def put(self, *a, **kw):\n            return httpx.Response(500, text=\"boom\", request=httpx.Request(\"PUT\", \"https://x\"))\n\n    monkeypatch.setattr(upload.httpx, \"Client\", FakeClient)\n    with pytest.raises(client.ApiError, match=\"HTTP 500\"):\n        upload._put_bytes(\"https://x\", b\"data\", \"image/png\")\n"
  },
  {
    "path": "tests/comfy_cli/command/generate/test_video_poll.py",
    "content": "\"\"\"Tests for the generic config-driven poller and per-partner specs.\"\"\"\n\nimport httpx\nimport pytest\n\nfrom comfy_cli.command.generate import poll\n\n\ndef _resp(body):\n    return httpx.Response(200, json=body)\n\n\n@pytest.fixture\ndef no_sleep(monkeypatch):\n    monkeypatch.setattr(poll, \"_sleep\", lambda *_: None)\n\n\ndef _make_runner(get_responses):\n    \"\"\"Patch client.get with an iterator over fake responses.\"\"\"\n    it = iter(get_responses)\n    return lambda *_a, **_kw: next(it)\n\n\ndef test_kling_sibling_poll_path(no_sleep, monkeypatch):\n    \"\"\"Kling builds the poll URL from {create_path}/{id}.\"\"\"\n    captured = {}\n\n    def fake_get(url, **kw):\n        captured[\"url\"] = url\n        return _resp({\"data\": {\"task_status\": \"succeed\", \"task_result\": {\"videos\": [{\"url\": \"https://cdn/v.mp4\"}]}}})\n\n    monkeypatch.setattr(\"comfy_cli.command.generate.client.get\", fake_get)\n    poller = poll.get_poller(\"kling\")\n    result = poller(\n        {\"data\": {\"task_id\": \"abc\"}},\n        api_key=\"comfyui-test\",\n        create_path=\"/proxy/kling/v1/videos/text2video\",\n    )\n    assert captured[\"url\"] == \"/proxy/kling/v1/videos/text2video/abc\"\n    assert result.status == \"succeeded\"\n    assert result.image_urls == [\"https://cdn/v.mp4\"]\n\n\ndef test_luma_succeeds(no_sleep, monkeypatch):\n    monkeypatch.setattr(\n        \"comfy_cli.command.generate.client.get\",\n        _make_runner(\n            [\n                _resp({\"id\": \"luma-1\", \"state\": \"dreaming\"}),\n                _resp({\"id\": \"luma-1\", \"state\": \"completed\", \"assets\": {\"video\": \"https://cdn/x.mp4\"}}),\n            ]\n        ),\n    )\n    result = poll.get_poller(\"luma\")({\"id\": \"luma-1\", \"state\": \"queued\"}, api_key=\"k\")\n    assert result.status == \"succeeded\"\n    assert \"https://cdn/x.mp4\" in result.image_urls\n\n\ndef test_runway_progress_normalized(no_sleep, monkeypatch):\n    \"\"\"Runway reports progress as 0–1 floats — the poller forwards as-is.\"\"\"\n    seen: list[float] = []\n    monkeypatch.setattr(\n        \"comfy_cli.command.generate.client.get\",\n        _make_runner(\n            [\n                _resp({\"id\": \"x\", \"status\": \"RUNNING\", \"progress\": 0.5}),\n                _resp({\"id\": \"x\", \"status\": \"SUCCEEDED\", \"output\": [\"https://cdn/v.mp4\"]}),\n            ]\n        ),\n    )\n    poll.get_poller(\"runway\")({\"id\": \"x\"}, api_key=\"k\", on_progress=seen.append)\n    assert seen == [0.5]\n\n\ndef test_runway_failure_states(no_sleep, monkeypatch):\n    monkeypatch.setattr(\n        \"comfy_cli.command.generate.client.get\",\n        _make_runner([_resp({\"id\": \"x\", \"status\": \"CANCELLED\"})]),\n    )\n    result = poll.get_poller(\"runway\")({\"id\": \"x\"}, api_key=\"k\")\n    assert result.status == \"failed\"\n    assert \"CANCELLED\" in (result.error or \"\")\n\n\ndef test_minimax_redeems_file_id(no_sleep, monkeypatch):\n    \"\"\"After Success, minimax needs a second GET to /files/retrieve to get the download URL.\"\"\"\n    monkeypatch.setattr(\n        \"comfy_cli.command.generate.client.get\",\n        _make_runner(\n            [\n                _resp({\"status\": \"Processing\", \"task_id\": \"t1\"}),\n                _resp({\"status\": \"Success\", \"task_id\": \"t1\", \"file_id\": \"f-42\"}),\n                _resp({\"file\": {\"download_url\": \"https://cdn/minimax.mp4\"}}),\n            ]\n        ),\n    )\n    result = poll.get_poller(\"minimax\")({\"task_id\": \"t1\"}, api_key=\"k\")\n    assert result.status == \"succeeded\"\n    assert \"https://cdn/minimax.mp4\" in result.image_urls\n\n\ndef test_pika_polls_videos_endpoint(no_sleep, monkeypatch):\n    captured = {}\n\n    def fake_get(url, **kw):\n        captured[\"url\"] = url\n        return _resp({\"id\": \"v1\", \"status\": \"finished\", \"url\": \"https://cdn/p.mp4\"})\n\n    monkeypatch.setattr(\"comfy_cli.command.generate.client.get\", fake_get)\n    result = poll.get_poller(\"pika\")({\"video_id\": \"v1\"}, api_key=\"k\")\n    assert captured[\"url\"] == \"/proxy/pika/videos/v1\"\n    assert result.status == \"succeeded\"\n\n\ndef test_vidu_polls_creations_path(no_sleep, monkeypatch):\n    captured = {}\n\n    def fake_get(url, **kw):\n        captured[\"url\"] = url\n        return _resp({\"state\": \"success\", \"creations\": [{\"url\": \"https://cdn/vidu.mp4\"}]})\n\n    monkeypatch.setattr(\"comfy_cli.command.generate.client.get\", fake_get)\n    poll.get_poller(\"vidu\")({\"task_id\": \"t1\"}, api_key=\"k\")\n    assert captured[\"url\"] == \"/proxy/vidu/tasks/t1/creations\"\n\n\ndef test_xai_video_polls_request_id(no_sleep, monkeypatch):\n    captured = {}\n\n    def fake_get(url, **kw):\n        captured[\"url\"] = url\n        return _resp({\"status\": \"done\", \"video\": {\"url\": \"https://cdn/x.mp4\"}})\n\n    monkeypatch.setattr(\"comfy_cli.command.generate.client.get\", fake_get)\n    poll.get_poller(\"xai_video\")({\"request_id\": \"req-1\"}, api_key=\"k\")\n    assert captured[\"url\"] == \"/proxy/xai/v1/videos/req-1\"\n\n\ndef test_moonvalley_polls_prompts(no_sleep, monkeypatch):\n    captured = {}\n\n    def fake_get(url, **kw):\n        captured[\"url\"] = url\n        return _resp({\"id\": \"p-1\", \"status\": \"completed\", \"output_url\": \"https://cdn/m.mp4\"})\n\n    monkeypatch.setattr(\"comfy_cli.command.generate.client.get\", fake_get)\n    poll.get_poller(\"moonvalley\")({\"id\": \"p-1\"}, api_key=\"k\")\n    assert captured[\"url\"] == \"/proxy/moonvalley/prompts/p-1\"\n\n\ndef test_missing_id_raises(monkeypatch):\n    monkeypatch.setattr(\"comfy_cli.command.generate.client.get\", lambda *a, **kw: _resp({}))\n    with pytest.raises(Exception, match=\"missing id\"):\n        poll.get_poller(\"kling\")({}, api_key=\"k\", create_path=\"/x\")\n\n\ndef test_kling_without_create_path_raises():\n    with pytest.raises(Exception, match=\"create path\"):\n        poll.get_poller(\"kling\")({\"data\": {\"task_id\": \"abc\"}}, api_key=\"k\")\n\n\ndef test_build_synthetic_initial_for_each_partner():\n    \"\"\"Sanity-check the resume helper for every registered partner.\"\"\"\n    for name in (\"kling\", \"luma\", \"minimax\", \"runway\", \"moonvalley\", \"pika\", \"vidu\", \"xai_video\"):\n        body = poll.build_synthetic_initial(name, \"abc\")\n        assert poll.extract_job_id(name, body) == \"abc\", name\n\n\ndef test_build_synthetic_initial_for_bfl():\n    body = poll.build_synthetic_initial(\"bfl\", \"abc\", base_url=\"https://api.comfy.org\")\n    assert \"polling_url\" in body\n    assert \"abc\" in body[\"polling_url\"]\n\n\ndef test_extract_urls_recognizes_video_extensions():\n    found = poll._extract_urls({\"video_url\": \"https://cdn/x.mp4\", \"ignore\": \"https://cdn/notmedia\"})\n    assert \"https://cdn/x.mp4\" in found\n    assert \"https://cdn/notmedia\" not in found\n\n\ndef test_extract_urls_recognizes_query_strings():\n    \"\"\"Signed URLs with ?Expires=… shouldn't be excluded by their query string.\"\"\"\n    found = poll._extract_urls({\"url\": \"https://cdn/v.mp4?Expires=123&Signature=abc\"})\n    assert found == [\"https://cdn/v.mp4?Expires=123&Signature=abc\"]\n\n\ndef test_extract_job_id_from_nested_paths():\n    assert poll.extract_job_id(\"kling\", {\"data\": {\"task_id\": \"k1\"}}) == \"k1\"\n    assert poll.extract_job_id(\"luma\", {\"id\": \"l1\"}) == \"l1\"\n    assert poll.extract_job_id(\"minimax\", {\"task_id\": \"m1\"}) == \"m1\"\n    assert poll.extract_job_id(\"xai_video\", {\"request_id\": \"x1\"}) == \"x1\"\n\n\ndef test_existing_bfl_poller_still_works(no_sleep, monkeypatch):\n    \"\"\"Regression: the original BFL adapter shouldn't be disturbed by the refactor.\"\"\"\n    monkeypatch.setattr(\n        \"comfy_cli.command.generate.client.get\",\n        _make_runner([_resp({\"status\": \"Ready\", \"result\": {\"sample\": \"https://cdn/b.png\"}})]),\n    )\n    result = poll.get_poller(\"bfl\")({\"polling_url\": \"https://x\"}, api_key=\"k\")\n    assert result.status == \"succeeded\"\n    assert \"https://cdn/b.png\" in result.image_urls\n"
  },
  {
    "path": "tests/comfy_cli/command/github/test_pr.py",
    "content": "import subprocess\nimport sys\nfrom unittest.mock import Mock, patch\n\nimport pytest\nimport requests\nfrom typer.testing import CliRunner\n\nfrom comfy_cli.cmdline import app, g_exclusivity, g_gpu_exclusivity\nfrom comfy_cli.command import install as install_module\nfrom comfy_cli.command.install import (\n    GitHubRateLimitError,\n    PRInfo,\n    _parse_github_owner_repo,\n    _resolve_latest_tag_from_local,\n    checkout_stable_comfyui,\n    fetch_pr_info,\n    find_pr_by_branch,\n    get_latest_release,\n    handle_github_rate_limit,\n    handle_pr_checkout,\n    parse_pr_reference,\n)\nfrom comfy_cli.git_utils import checkout_pr, git_checkout_tag\n\n\n@pytest.fixture(scope=\"function\")\ndef runner():\n    g_exclusivity.reset_for_testing()\n    g_gpu_exclusivity.reset_for_testing()\n    return CliRunner()\n\n\n@pytest.fixture\ndef sample_pr_info():\n    return PRInfo(\n        number=123,\n        head_repo_url=\"https://github.com/jtydhr88/ComfyUI.git\",\n        head_branch=\"load-3d-nodes\",\n        base_repo_url=\"https://github.com/comfyanonymous/ComfyUI.git\",\n        base_branch=\"master\",\n        title=\"Add 3D node loading support\",\n        user=\"jtydhr88\",\n        mergeable=True,\n    )\n\n\nclass TestPRReferenceParsing:\n    def test_parse_pr_number_format(self):\n        \"\"\"Test parsing #123 format\"\"\"\n        repo_owner, repo_name, pr_number = parse_pr_reference(\"#123\")\n        assert repo_owner == \"comfyanonymous\"\n        assert repo_name == \"ComfyUI\"\n        assert pr_number == 123\n\n    def test_parse_user_branch_format(self):\n        \"\"\"Test parsing username:branch format\"\"\"\n        repo_owner, repo_name, pr_number = parse_pr_reference(\"jtydhr88:load-3d-nodes\")\n        assert repo_owner == \"jtydhr88\"\n        assert repo_name == \"ComfyUI\"\n        assert pr_number is None\n\n    def test_parse_github_url_format(self):\n        \"\"\"Test parsing full GitHub PR URL\"\"\"\n        url = \"https://github.com/comfyanonymous/ComfyUI/pull/456\"\n        repo_owner, repo_name, pr_number = parse_pr_reference(url)\n        assert repo_owner == \"comfyanonymous\"\n        assert repo_name == \"ComfyUI\"\n        assert pr_number == 456\n\n    def test_parse_invalid_format(self):\n        \"\"\"Test parsing invalid format raises ValueError\"\"\"\n        with pytest.raises(ValueError, match=\"Invalid PR reference format\"):\n            parse_pr_reference(\"invalid-format\")\n\n    def test_parse_empty_string(self):\n        \"\"\"Test parsing empty string raises ValueError\"\"\"\n        with pytest.raises(ValueError):\n            parse_pr_reference(\"\")\n\n\nclass TestGitHubAPIIntegration:\n    \"\"\"Test GitHub API integration\"\"\"\n\n    @patch(\"requests.get\")\n    def test_fetch_pr_info_success(self, mock_get, sample_pr_info):\n        \"\"\"Test successful PR info fetching\"\"\"\n        # Mock API response\n        mock_response = Mock()\n        mock_response.status_code = 200\n        mock_response.json.return_value = {\n            \"number\": 123,\n            \"title\": \"Add 3D node loading support\",\n            \"head\": {\n                \"repo\": {\"clone_url\": \"https://github.com/jtydhr88/ComfyUI.git\", \"owner\": {\"login\": \"jtydhr88\"}},\n                \"ref\": \"load-3d-nodes\",\n            },\n            \"base\": {\"repo\": {\"clone_url\": \"https://github.com/comfyanonymous/ComfyUI.git\"}, \"ref\": \"master\"},\n            \"mergeable\": True,\n        }\n        mock_get.return_value = mock_response\n\n        result = fetch_pr_info(\"comfyanonymous\", \"ComfyUI\", 123)\n\n        assert result.number == 123\n        assert result.title == \"Add 3D node loading support\"\n        assert result.user == \"jtydhr88\"\n        assert result.head_branch == \"load-3d-nodes\"\n        assert result.mergeable is True\n\n    @patch(\"requests.get\")\n    def test_fetch_pr_info_not_found(self, mock_get):\n        \"\"\"Test PR not found (404)\"\"\"\n        mock_response = Mock()\n        mock_response.status_code = 404\n        mock_response.raise_for_status.side_effect = requests.HTTPError(\"404 Not Found\")\n        mock_get.return_value = mock_response\n\n        with pytest.raises(Exception, match=\"Failed to fetch PR\"):\n            fetch_pr_info(\"comfyanonymous\", \"ComfyUI\", 999)\n\n    @patch(\"requests.get\")\n    def test_fetch_pr_info_rate_limit(self, mock_get):\n        \"\"\"Test GitHub API rate limit handling\"\"\"\n        mock_response = Mock()\n        mock_response.status_code = 403\n        mock_response.headers = {\"x-ratelimit-remaining\": \"0\"}\n        mock_get.return_value = mock_response\n\n        with pytest.raises(Exception, match=\"Primary rate limit from Github exceeded!\"):\n            fetch_pr_info(\"comfyanonymous\", \"ComfyUI\", 123)\n\n    @patch(\"requests.get\")\n    def test_find_pr_by_branch_success(self, mock_get):\n        \"\"\"Test successful PR search by branch\"\"\"\n        mock_response = Mock()\n        mock_response.status_code = 200\n        mock_response.json.return_value = [\n            {\n                \"number\": 456,\n                \"title\": \"Test PR\",\n                \"head\": {\n                    \"repo\": {\"clone_url\": \"https://github.com/testuser/ComfyUI.git\", \"owner\": {\"login\": \"testuser\"}},\n                    \"ref\": \"test-branch\",\n                },\n                \"base\": {\"repo\": {\"clone_url\": \"https://github.com/comfyanonymous/ComfyUI.git\"}, \"ref\": \"master\"},\n                \"mergeable\": True,\n            }\n        ]\n        mock_get.return_value = mock_response\n\n        result = find_pr_by_branch(\"comfyanonymous\", \"ComfyUI\", \"testuser\", \"test-branch\")\n\n        assert result is not None\n        assert result.number == 456\n        assert result.title == \"Test PR\"\n        assert result.user == \"testuser\"\n        assert result.head_branch == \"test-branch\"\n\n    @patch(\"requests.get\")\n    def test_find_pr_by_branch_not_found(self, mock_get):\n        \"\"\"Test PR not found by branch\"\"\"\n        mock_response = Mock()\n        mock_response.status_code = 200\n        mock_response.json.return_value = []\n        mock_get.return_value = mock_response\n\n        result = find_pr_by_branch(\"comfyanonymous\", \"ComfyUI\", \"testuser\", \"nonexistent-branch\")\n        assert result is None\n\n    @patch(\"requests.get\")\n    def test_find_pr_by_branch_error(self, mock_get):\n        \"\"\"Test error when searching PR by branch\"\"\"\n        mock_get.side_effect = requests.RequestException(\"Network error\")\n\n        result = find_pr_by_branch(\"comfyanonymous\", \"ComfyUI\", \"testuser\", \"test-branch\")\n        assert result is None\n\n\nclass TestGitOperations:\n    \"\"\"Test Git operations for PR checkout\"\"\"\n\n    @patch(\"subprocess.run\")\n    @patch(\"os.chdir\")\n    @patch(\"os.getcwd\")\n    def test_checkout_pr_fork_success(self, mock_getcwd, mock_chdir, mock_subprocess, sample_pr_info):\n        \"\"\"Test successful checkout of PR from fork\"\"\"\n        mock_getcwd.return_value = \"/original/dir\"\n\n        mock_subprocess.side_effect = [\n            subprocess.CompletedProcess([], 1),\n            subprocess.CompletedProcess([], 0),\n            subprocess.CompletedProcess([], 0),\n            subprocess.CompletedProcess([], 0),\n        ]\n\n        result = checkout_pr(\"/repo/path\", sample_pr_info)\n\n        assert result is True\n        assert mock_subprocess.call_count == 4\n\n        calls = mock_subprocess.call_args_list\n        assert \"git\" in calls[0][0][0]\n        assert \"remote\" in calls[1][0][0]\n        assert \"fetch\" in calls[2][0][0]\n        assert \"checkout\" in calls[3][0][0]\n\n    @patch(\"subprocess.run\")\n    @patch(\"os.chdir\")\n    @patch(\"os.getcwd\")\n    def test_checkout_pr_non_fork_success(self, mock_getcwd, mock_chdir, mock_subprocess):\n        \"\"\"Test successful checkout of PR from same repo\"\"\"\n        mock_getcwd.return_value = \"/original/dir\"\n\n        pr_info = PRInfo(\n            number=123,\n            head_repo_url=\"https://github.com/comfyanonymous/ComfyUI.git\",\n            head_branch=\"feature-branch\",\n            base_repo_url=\"https://github.com/comfyanonymous/ComfyUI.git\",\n            base_branch=\"master\",\n            title=\"Feature branch\",\n            user=\"comfyanonymous\",\n            mergeable=True,\n        )\n\n        mock_subprocess.side_effect = [\n            subprocess.CompletedProcess([], 0),  # fetch succeeds\n            subprocess.CompletedProcess([], 0),  # checkout succeeds\n        ]\n\n        result = checkout_pr(\"/repo/path\", pr_info)\n\n        assert result is True\n        assert mock_subprocess.call_count == 2\n\n    @patch(\"subprocess.run\")\n    @patch(\"os.chdir\")\n    @patch(\"os.getcwd\")\n    def test_checkout_pr_git_failure(self, mock_getcwd, mock_chdir, mock_subprocess, sample_pr_info):\n        \"\"\"Test Git operation failure\"\"\"\n        mock_getcwd.return_value = \"/original/dir\"\n\n        error = subprocess.CalledProcessError(1, \"git\", stderr=\"Permission denied\")\n        mock_subprocess.side_effect = error\n\n        result = checkout_pr(\"/repo/path\", sample_pr_info)\n\n        assert result is False\n\n\nclass TestGitCheckoutTag:\n    \"\"\"Cover ``git_checkout_tag``'s skip-fetch-when-tag-is-local behavior.\n\n    The fetch is intentionally avoided when the tag already exists in the\n    local clone, both to skip a redundant network round-trip on the happy\n    path and to let offline installs succeed when the caller (e.g. the\n    `--version latest` resolver) already validated a cached tag.\n    \"\"\"\n\n    @staticmethod\n    def _init_repo(path):\n        subprocess.run([\"git\", \"init\", \"-q\", str(path)], check=True)\n        subprocess.run([\"git\", \"-C\", str(path), \"config\", \"user.email\", \"x@x\"], check=True)\n        subprocess.run([\"git\", \"-C\", str(path), \"config\", \"user.name\", \"x\"], check=True)\n        subprocess.run(\n            [\"git\", \"-C\", str(path), \"commit\", \"--allow-empty\", \"-m\", \"init\", \"-q\"],\n            check=True,\n        )\n\n    def test_succeeds_offline_when_tag_already_local(self, tmp_path):\n        \"\"\"The bug: cached-tag offline path must not crash on the redundant fetch.\n\n        Repro: tag exists locally + origin is unreachable. Old code would call\n        `git fetch --tags` with check=True and fail; new code skips the fetch\n        because the tag is already present.\n        \"\"\"\n        self._init_repo(tmp_path)\n        subprocess.run([\"git\", \"-C\", str(tmp_path), \"tag\", \"v0.20.1\"], check=True)\n        # Point origin at an unreachable path so any fetch attempt would fail.\n        subprocess.run(\n            [\"git\", \"-C\", str(tmp_path), \"remote\", \"add\", \"origin\", \"file:///nonexistent-repo-path-for-test\"],\n            check=True,\n        )\n\n        result = git_checkout_tag(str(tmp_path), \"v0.20.1\")\n        assert result is True\n\n        # HEAD really moved to the tag\n        head = subprocess.run(\n            [\"git\", \"-C\", str(tmp_path), \"describe\", \"--tags\", \"--exact-match\", \"HEAD\"],\n            capture_output=True,\n            text=True,\n            check=True,\n        )\n        assert head.stdout.strip() == \"v0.20.1\"\n\n    def test_fetches_when_tag_missing_locally(self, tmp_path):\n        \"\"\"When the tag isn't local we must still fetch — and an unreachable\n        remote is then a real, surfaced error (not silently swallowed).\"\"\"\n        self._init_repo(tmp_path)\n        # Tag is NOT created locally\n        subprocess.run(\n            [\"git\", \"-C\", str(tmp_path), \"remote\", \"add\", \"origin\", \"file:///nonexistent-repo-path-for-test\"],\n            check=True,\n        )\n\n        result = git_checkout_tag(str(tmp_path), \"v0.20.1\")\n        assert result is False  # fetch failed, surfaced as a checkout failure\n\n\nclass TestHandlePRCheckout:\n    \"\"\"Test the main PR checkout handler\"\"\"\n\n    @patch(\"comfy_cli.command.install.parse_pr_reference\")\n    @patch(\"comfy_cli.command.install.fetch_pr_info\")\n    @patch(\"comfy_cli.command.install.checkout_pr\")\n    @patch(\"comfy_cli.command.install.clone_comfyui\")\n    @patch(\"comfy_cli.ui.prompt_confirm_action\")\n    @patch(\"os.path.exists\")\n    @patch(\"os.makedirs\")\n    def test_handle_pr_checkout_success(\n        self,\n        mock_makedirs,\n        mock_exists,\n        mock_confirm,\n        mock_clone,\n        mock_checkout,\n        mock_fetch,\n        mock_parse,\n        sample_pr_info,\n    ):\n        \"\"\"Test successful PR checkout handling\"\"\"\n        mock_parse.return_value = (\"jtydhr88\", \"ComfyUI\", 123)\n        mock_fetch.return_value = sample_pr_info\n        mock_exists.side_effect = [True, False]  # Parent exists, repo doesn't\n        mock_confirm.return_value = True\n        mock_checkout.return_value = True\n\n        with patch(\"comfy_cli.command.install.workspace_manager\") as mock_ws:\n            mock_ws.skip_prompting = False\n\n            result = handle_pr_checkout(\"jtydhr88:load-3d-nodes\", \"/path/to/comfy\")\n\n            assert result == \"https://github.com/comfyanonymous/ComfyUI.git\"\n            mock_clone.assert_called_once()\n            mock_checkout.assert_called_once()\n\n\nclass TestCommandLineIntegration:\n    \"\"\"Test command line integration\"\"\"\n\n    @patch(\"comfy_cli.command.install.execute\")\n    def test_install_with_pr_parameter(self, mock_execute, runner):\n        \"\"\"Test install command with --pr parameter\"\"\"\n        result = runner.invoke(app, [\"install\", \"--pr\", \"jtydhr88:load-3d-nodes\", \"--nvidia\", \"--skip-prompt\"])\n\n        assert \"Invalid PR reference format\" not in result.stdout\n\n        if mock_execute.called:\n            call_args = mock_execute.call_args\n            assert \"pr\" in call_args.kwargs or len(call_args.args) > 8\n\n    def test_pr_and_version_conflict(self, runner):\n        \"\"\"Test that --pr conflicts with --version\"\"\"\n        result = runner.invoke(app, [\"install\", \"--pr\", \"#123\", \"--version\", \"1.0.0\"])\n\n        assert result.exit_code != 0\n\n    def test_pr_and_commit_conflict(self, runner):\n        \"\"\"Test that --pr conflicts with --commit\"\"\"\n        result = runner.invoke(app, [\"install\", \"--pr\", \"#123\", \"--version\", \"nightly\", \"--commit\", \"abc123\"])\n\n        assert result.exit_code != 0\n\n    @patch(\"comfy_cli.command.install.execute\")\n    @patch(\"comfy_cli.cmdline.check_comfy_repo\", return_value=(False, None))\n    @patch(\"comfy_cli.cmdline.workspace_manager\")\n    @patch(\"comfy_cli.tracking.prompt_tracking_consent\")\n    def test_commit_without_pr_does_not_conflict(self, mock_track, mock_ws, mock_check, mock_execute, runner):\n        \"\"\"Test that --commit alone does not trigger --pr conflict error (issue #335)\"\"\"\n        mock_ws.get_workspace_path.return_value = (\"/tmp/test\", None)\n        result = runner.invoke(\n            app, [\"--skip-prompt\", \"install\", \"--version\", \"nightly\", \"--commit\", \"abc123\", \"--nvidia\"]\n        )\n\n        assert \"--pr cannot be used\" not in result.stdout\n        assert mock_execute.called\n\n    @patch(\"comfy_cli.command.install.execute\")\n    @patch(\"comfy_cli.cmdline.check_comfy_repo\", return_value=(False, None))\n    @patch(\"comfy_cli.cmdline.workspace_manager\")\n    @patch(\"comfy_cli.tracking.prompt_tracking_consent\")\n    def test_cpu_pr_conflict_with_version(self, mock_track, mock_ws, mock_check, mock_execute, runner):\n        \"\"\"Test that --cpu --pr with --version is rejected\"\"\"\n        mock_ws.get_workspace_path.return_value = (\"/tmp/test\", None)\n        result = runner.invoke(app, [\"--skip-prompt\", \"install\", \"--cpu\", \"--pr\", \"#123\", \"--version\", \"1.0.0\"])\n\n        assert result.exit_code != 0\n        assert \"--pr cannot be used\" in result.stdout\n        assert not mock_execute.called\n\n    @patch(\"comfy_cli.command.install.execute\")\n    @patch(\"comfy_cli.cmdline.check_comfy_repo\", return_value=(False, None))\n    @patch(\"comfy_cli.cmdline.workspace_manager\")\n    @patch(\"comfy_cli.tracking.prompt_tracking_consent\")\n    def test_cpu_pr_conflict_with_commit(self, mock_track, mock_ws, mock_check, mock_execute, runner):\n        \"\"\"Test that --cpu --pr with --commit is rejected\"\"\"\n        mock_ws.get_workspace_path.return_value = (\"/tmp/test\", None)\n        result = runner.invoke(\n            app, [\"--skip-prompt\", \"install\", \"--cpu\", \"--pr\", \"#123\", \"--version\", \"nightly\", \"--commit\", \"abc123\"]\n        )\n\n        assert result.exit_code != 0\n        assert \"--pr cannot be used\" in result.stdout\n        assert not mock_execute.called\n\n    @patch(\"comfy_cli.command.install.execute\")\n    @patch(\"comfy_cli.cmdline.check_comfy_repo\", return_value=(False, None))\n    @patch(\"comfy_cli.cmdline.workspace_manager\")\n    @patch(\"comfy_cli.tracking.prompt_tracking_consent\")\n    def test_cpu_pr_passes_pr_to_execute(self, mock_track, mock_ws, mock_check, mock_execute, runner):\n        \"\"\"Test that --cpu --pr passes pr parameter to install_inner.execute\"\"\"\n        mock_ws.get_workspace_path.return_value = (\"/tmp/test\", None)\n        runner.invoke(app, [\"--skip-prompt\", \"install\", \"--cpu\", \"--pr\", \"#123\"])\n\n        assert mock_execute.called\n        call_kwargs = mock_execute.call_args.kwargs\n        assert call_kwargs.get(\"pr\") == \"#123\"\n\n\nclass TestPRInfoDataClass:\n    \"\"\"Test PRInfo data class\"\"\"\n\n    def test_pr_info_is_fork_true(self):\n        \"\"\"Test is_fork property returns True for fork\"\"\"\n        pr_info = PRInfo(\n            number=123,\n            head_repo_url=\"https://github.com/user/ComfyUI.git\",\n            head_branch=\"branch\",\n            base_repo_url=\"https://github.com/comfyanonymous/ComfyUI.git\",\n            base_branch=\"master\",\n            title=\"Title\",\n            user=\"user\",\n            mergeable=True,\n        )\n        assert pr_info.is_fork is True\n\n    def test_pr_info_is_fork_false(self):\n        \"\"\"Test is_fork property returns False for same repo\"\"\"\n        pr_info = PRInfo(\n            number=123,\n            head_repo_url=\"https://github.com/comfyanonymous/ComfyUI.git\",\n            head_branch=\"feature\",\n            base_repo_url=\"https://github.com/comfyanonymous/ComfyUI.git\",\n            base_branch=\"master\",\n            title=\"Title\",\n            user=\"comfyanonymous\",\n            mergeable=True,\n        )\n        assert pr_info.is_fork is False\n\n\nclass TestEdgeCases:\n    \"\"\"Test edge cases and error conditions\"\"\"\n\n    def test_parse_pr_reference_whitespace(self):\n        \"\"\"Test parsing with whitespace\"\"\"\n        repo_owner, repo_name, pr_number = parse_pr_reference(\"  #123  \")\n        assert repo_owner == \"comfyanonymous\"\n        assert repo_name == \"ComfyUI\"\n        assert pr_number == 123\n\n    @patch(\"requests.get\")\n    def test_fetch_pr_info_with_github_token(self, mock_get):\n        \"\"\"Test PR fetching with GitHub token\"\"\"\n        mock_response = Mock()\n        mock_response.status_code = 200\n        mock_response.json.return_value = {\n            \"number\": 123,\n            \"title\": \"Test\",\n            \"head\": {\"repo\": {\"clone_url\": \"url\", \"owner\": {\"login\": \"user\"}}, \"ref\": \"branch\"},\n            \"base\": {\"repo\": {\"clone_url\": \"base_url\"}, \"ref\": \"master\"},\n            \"mergeable\": True,\n        }\n        mock_get.return_value = mock_response\n\n        with patch.dict(\"os.environ\", {\"GITHUB_TOKEN\": \"test-token\"}):\n            fetch_pr_info(\"owner\", \"repo\", 123)\n\n            call_args = mock_get.call_args\n            headers = call_args.kwargs.get(\"headers\", {})\n            assert \"Authorization\" in headers\n            assert headers[\"Authorization\"] == \"Bearer test-token\"\n\n    @patch(\"subprocess.run\")\n    @patch(\"os.chdir\")\n    @patch(\"os.getcwd\")\n    def test_checkout_pr_remote_already_exists(self, mock_getcwd, mock_chdir, mock_subprocess, sample_pr_info):\n        \"\"\"Test checkout when remote already exists\"\"\"\n        mock_getcwd.return_value = \"/dir\"\n\n        mock_subprocess.side_effect = [\n            subprocess.CompletedProcess([], 0),\n            subprocess.CompletedProcess([], 0),\n            subprocess.CompletedProcess([], 0),\n        ]\n\n        result = checkout_pr(\"/repo\", sample_pr_info)\n\n        assert result is True\n        assert mock_subprocess.call_count == 3\n\n\nclass TestGetLatestRelease:\n    \"\"\"Test get_latest_release GitHub API calls\"\"\"\n\n    @patch(\"requests.get\")\n    def test_sends_auth_header_when_token_set(self, mock_get):\n        \"\"\"Ensure GITHUB_TOKEN is sent as Bearer auth to avoid rate limits (issue #425)\"\"\"\n        mock_response = Mock()\n        mock_response.status_code = 200\n        mock_response.json.return_value = {\n            \"tag_name\": \"v0.18.2\",\n            \"zipball_url\": \"https://github.com/comfyanonymous/ComfyUI/archive/v0.18.2.zip\",\n        }\n        mock_get.return_value = mock_response\n\n        with patch.dict(\"os.environ\", {\"GITHUB_TOKEN\": \"ghp_test123\"}):\n            result = get_latest_release(\"comfyanonymous\", \"ComfyUI\")\n\n        headers = mock_get.call_args.kwargs.get(\"headers\", {})\n        assert headers[\"Authorization\"] == \"Bearer ghp_test123\"\n        assert result is not None\n        assert result[\"tag\"] == \"v0.18.2\"\n\n    @patch(\"requests.get\")\n    def test_no_auth_header_without_token(self, mock_get):\n        \"\"\"Without GITHUB_TOKEN the request has no Authorization header\"\"\"\n        mock_response = Mock()\n        mock_response.status_code = 200\n        mock_response.json.return_value = {\n            \"tag_name\": \"v0.18.2\",\n            \"zipball_url\": \"https://github.com/comfyanonymous/ComfyUI/archive/v0.18.2.zip\",\n        }\n        mock_get.return_value = mock_response\n\n        with patch.dict(\"os.environ\", {}, clear=True):\n            get_latest_release(\"comfyanonymous\", \"ComfyUI\")\n\n        headers = mock_get.call_args.kwargs.get(\"headers\", {})\n        assert \"Authorization\" not in headers\n\n    @patch(\"requests.get\")\n    def test_rate_limit_raises_error(self, mock_get):\n        \"\"\"A 403 with exhausted rate limit raises GitHubRateLimitError\"\"\"\n        mock_response = Mock()\n        mock_response.status_code = 403\n        mock_response.headers = {\"x-ratelimit-remaining\": \"0\", \"x-ratelimit-reset\": \"1700000000\"}\n        mock_get.return_value = mock_response\n\n        with pytest.raises(GitHubRateLimitError):\n            get_latest_release(\"comfyanonymous\", \"ComfyUI\")\n\n    @patch(\"requests.get\")\n    def test_non_semver_tag_returns_release_with_version_none(self, mock_get):\n        \"\"\"Forks may use non-semver tags (e.g. `release-2026-04`); the parser\n        must not crash — caller only needs the raw tag string for checkout.\"\"\"\n        mock_response = Mock()\n        mock_response.status_code = 200\n        mock_response.json.return_value = {\n            \"tag_name\": \"release-2026-04\",\n            \"zipball_url\": \"https://example/zip\",\n        }\n        mock_get.return_value = mock_response\n\n        result = get_latest_release(\"some-fork\", \"ComfyUI\")\n\n        assert result is not None\n        assert result[\"tag\"] == \"release-2026-04\"\n        assert result[\"version\"] is None\n\n\nclass TestHandleGithubRateLimit:\n    def test_primary_rate_limit_message_format(self):\n        \"\"\"Verify the error message does not contain stray characters.\"\"\"\n        mock_response = Mock()\n        mock_response.headers = {\"x-ratelimit-remaining\": \"0\", \"x-ratelimit-reset\": \"1700000000\"}\n\n        with pytest.raises(GitHubRateLimitError) as exc_info:\n            handle_github_rate_limit(mock_response)\n\n        msg = str(exc_info.value)\n        assert \"1700000000\" in msg\n        assert msg.endswith(\"1700000000\")  # no stray trailing characters\n\n    def test_retry_after_header(self):\n        mock_response = Mock()\n        mock_response.headers = {\"x-ratelimit-remaining\": \"5\", \"retry-after\": \"30\"}\n\n        with pytest.raises(GitHubRateLimitError, match=\"30 seconds\"):\n            handle_github_rate_limit(mock_response)\n\n    def test_no_rate_limit_does_not_raise(self):\n        mock_response = Mock()\n        mock_response.headers = {\"x-ratelimit-remaining\": \"100\"}\n\n        handle_github_rate_limit(mock_response)  # should not raise\n\n\nclass TestResolveLatestTagFromLocal:\n    \"\"\"Cover the local-tag resolver added for issue #440 — `--version latest`\n    must not require a GitHub API hit when tags are already on disk.\"\"\"\n\n    @staticmethod\n    def _init_repo(path):\n        subprocess.run([\"git\", \"init\", \"-q\", str(path)], check=True)\n        subprocess.run([\"git\", \"-C\", str(path), \"config\", \"user.email\", \"x@x\"], check=True)\n        subprocess.run([\"git\", \"-C\", str(path), \"config\", \"user.name\", \"x\"], check=True)\n        subprocess.run(\n            [\"git\", \"-C\", str(path), \"commit\", \"--allow-empty\", \"-m\", \"init\", \"-q\"],\n            check=True,\n        )\n\n    @classmethod\n    def _make_repo(cls, path, tags):\n        cls._init_repo(path)\n        for tag in tags:\n            subprocess.run([\"git\", \"-C\", str(path), \"tag\", tag], check=True)\n\n    def test_picks_highest_stable_semver(self, tmp_path):\n        self._make_repo(tmp_path, [\"v0.19.5\", \"v0.20.0\", \"v0.20.1\", \"v0.18.2\"])\n        tag, _fetch_ok = _resolve_latest_tag_from_local(str(tmp_path))\n        assert tag == \"v0.20.1\"\n\n    def test_skips_pre_release_tags(self, tmp_path):\n        \"\"\"GitHub's releases/latest excludes pre-releases; we mirror that.\"\"\"\n        self._make_repo(tmp_path, [\"v0.20.0\", \"v0.20.1\", \"v0.21.0-rc1\", \"v0.21.0-beta.1\"])\n        tag, _ = _resolve_latest_tag_from_local(str(tmp_path))\n        assert tag == \"v0.20.1\"\n\n    def test_skips_non_semver_tags(self, tmp_path):\n        self._make_repo(tmp_path, [\"v0.20.1\", \"release-foo\", \"nightly\", \"weird/slash\"])\n        tag, _ = _resolve_latest_tag_from_local(str(tmp_path))\n        assert tag == \"v0.20.1\"\n\n    def test_returns_none_when_no_tags(self, tmp_path):\n        self._init_repo(tmp_path)\n        tag, _ = _resolve_latest_tag_from_local(str(tmp_path))\n        assert tag is None\n\n    def test_returns_none_when_only_prereleases(self, tmp_path):\n        self._make_repo(tmp_path, [\"v1.0.0-rc1\", \"v1.0.0-beta\"])\n        tag, _ = _resolve_latest_tag_from_local(str(tmp_path))\n        assert tag is None\n\n    def test_returns_none_when_only_non_semver(self, tmp_path):\n        self._make_repo(tmp_path, [\"main\", \"release-foo\", \"nightly\"])\n        tag, _ = _resolve_latest_tag_from_local(str(tmp_path))\n        assert tag is None\n\n    def test_returns_none_for_non_git_directory(self, tmp_path):\n        tag, fetch_ok = _resolve_latest_tag_from_local(str(tmp_path))\n        assert tag is None\n        assert fetch_ok is False\n\n    def test_tolerates_fetch_exception(self, tmp_path):\n        \"\"\"Fetch may raise (timeout, OSError) — resolver should still use local tags.\"\"\"\n        self._make_repo(tmp_path, [\"v0.20.1\"])\n\n        real_run = subprocess.run\n\n        def flaky(args, **kwargs):\n            if len(args) >= 4 and args[3] == \"fetch\":\n                raise subprocess.SubprocessError(\"simulated network failure\")\n            return real_run(args, **kwargs)\n\n        with patch(\"comfy_cli.command.install.subprocess.run\", side_effect=flaky):\n            tag, fetch_ok = _resolve_latest_tag_from_local(str(tmp_path))\n\n        assert tag == \"v0.20.1\"\n        assert fetch_ok is False\n\n    def test_tolerates_fetch_nonzero_exit(self, tmp_path):\n        \"\"\"Fetch may exit non-zero without raising (auth, network, bad remote).\n\n        Without ``check=True`` subprocess.run silently returns a non-zero\n        CompletedProcess. The resolver should still produce tags from disk\n        and report ``fetch_ok=False`` so the caller can warn the user.\n        \"\"\"\n        self._make_repo(tmp_path, [\"v0.20.0\", \"v0.20.1\"])\n        # Point origin at a path that doesn't exist → fetch exits 128 without raising in Python\n        subprocess.run(\n            [\"git\", \"-C\", str(tmp_path), \"remote\", \"add\", \"origin\", \"file:///nonexistent-repo-path-for-test\"],\n            check=True,\n        )\n\n        tag, fetch_ok = _resolve_latest_tag_from_local(str(tmp_path))\n        assert tag == \"v0.20.1\"\n        assert fetch_ok is False\n\n    def test_tag_with_v_prefix_normalized(self, tmp_path):\n        \"\"\"Tags may be present with or without the leading 'v'; the higher stable wins.\"\"\"\n        self._make_repo(tmp_path, [\"v0.20.0\", \"0.20.1\"])\n        tag, _ = _resolve_latest_tag_from_local(str(tmp_path))\n        assert tag == \"0.20.1\"\n\n\nclass TestParseGithubOwnerRepo:\n    \"\"\"Cover the URL parser used by the API fallback to query the same repo\n    we cloned from (forks included), instead of always asking upstream.\"\"\"\n\n    @pytest.mark.parametrize(\n        \"url,expected\",\n        [\n            # The default URL the install command uses\n            (\"https://github.com/comfyanonymous/ComfyUI\", (\"comfyanonymous\", \"ComfyUI\")),\n            # With .git suffix\n            (\"https://github.com/comfyanonymous/ComfyUI.git\", (\"comfyanonymous\", \"ComfyUI\")),\n            # With trailing slash\n            (\"https://github.com/comfyanonymous/ComfyUI/\", (\"comfyanonymous\", \"ComfyUI\")),\n            # setuptools-style @branch suffix that clone_comfyui supports\n            (\"https://github.com/comfyanonymous/ComfyUI@master\", (\"comfyanonymous\", \"ComfyUI\")),\n            (\"https://github.com/comfyanonymous/ComfyUI.git@release/1.0\", (\"comfyanonymous\", \"ComfyUI\")),\n            # Forks\n            (\"https://github.com/myfork/ComfyUI\", (\"myfork\", \"ComfyUI\")),\n            (\"https://github.com/some-user/some-repo.git\", (\"some-user\", \"some-repo\")),\n            # SSH forms\n            (\"git@github.com:comfyanonymous/ComfyUI\", (\"comfyanonymous\", \"ComfyUI\")),\n            (\"git@github.com:comfyanonymous/ComfyUI.git\", (\"comfyanonymous\", \"ComfyUI\")),\n        ],\n    )\n    def test_parses_github_urls(self, url, expected):\n        assert _parse_github_owner_repo(url) == expected\n\n    @pytest.mark.parametrize(\n        \"url\",\n        [\n            None,\n            \"\",\n            \"/local/path/to/comfyui\",  # local path\n            \"https://gitlab.com/foo/bar\",  # not GitHub\n            \"https://example.com/owner/repo\",  # not GitHub\n            \"https://github.com/owner/repo/pull/123\",  # not a repo URL\n            \"ftp://github.com/owner/repo\",  # exotic scheme — still parses since regex matches `github.com/...`\n        ],\n    )\n    def test_returns_none_for_non_github_urls(self, url):\n        # The PR URL form (last case) intentionally doesn't match — `[^/@]+?` excludes `/`\n        # so `repo/pull/123` cannot be the second capture; we want this to fall through\n        # to the upstream default in the caller.\n        if url == \"ftp://github.com/owner/repo\":\n            # Edge-case: this DOES match because we don't anchor on the scheme.\n            # That's fine — owner/repo is what matters; the API call uses HTTPS regardless.\n            assert _parse_github_owner_repo(url) == (\"owner\", \"repo\")\n        else:\n            assert _parse_github_owner_repo(url) is None\n\n\nclass TestCheckoutStableComfyUI:\n    \"\"\"Verify checkout_stable_comfyui prefers local tag resolution over the\n    GitHub API for `--version latest` (issue #440), and falls back when local\n    resolution fails.\"\"\"\n\n    @patch(\"comfy_cli.command.install.git_checkout_tag\", return_value=True)\n    @patch(\"comfy_cli.command.install.get_latest_release\")\n    @patch(\"comfy_cli.command.install._resolve_latest_tag_from_local\", return_value=(\"v0.20.1\", True))\n    def test_latest_uses_local_tag_no_api_call(self, mock_local, mock_api, mock_co):\n        \"\"\"When local tags resolve, the API is never consulted.\"\"\"\n        checkout_stable_comfyui(\"latest\", \"/repo\")\n\n        mock_local.assert_called_once_with(\"/repo\")\n        mock_api.assert_not_called()\n        mock_co.assert_called_once_with(\"/repo\", \"v0.20.1\")\n\n    @patch(\"comfy_cli.command.install.git_checkout_tag\", return_value=True)\n    @patch(\"comfy_cli.command.install.get_latest_release\")\n    @patch(\"comfy_cli.command.install._resolve_latest_tag_from_local\", return_value=(\"v0.20.1\", False))\n    def test_latest_warns_on_stale_tag_when_fetch_failed(self, mock_local, mock_api, mock_co, capsys):\n        \"\"\"Fetch failed but a tag was found locally → warn the user it may be stale.\n\n        Old behavior was to hard-fail via the API path; new behavior succeeds with\n        whatever's on disk. Without this warning the user has no way to tell the\n        clone is stale.\n        \"\"\"\n        checkout_stable_comfyui(\"latest\", \"/repo\")\n\n        captured = capsys.readouterr()\n        assert \"could not refresh tags from remote\" in captured.out\n        assert \"v0.20.1\" in captured.out\n        # Still uses the cached tag, no API call\n        mock_api.assert_not_called()\n        mock_co.assert_called_once_with(\"/repo\", \"v0.20.1\")\n\n    @patch(\"comfy_cli.command.install.git_checkout_tag\", return_value=True)\n    @patch(\"comfy_cli.command.install.get_latest_release\")\n    @patch(\"comfy_cli.command.install._resolve_latest_tag_from_local\", return_value=(\"v0.20.1\", True))\n    def test_latest_no_warning_when_fetch_succeeded(self, mock_local, mock_api, mock_co, capsys):\n        \"\"\"Happy path: fetch_ok=True → no stale-tag warning, quiet success.\"\"\"\n        checkout_stable_comfyui(\"latest\", \"/repo\")\n\n        captured = capsys.readouterr()\n        assert \"could not refresh tags\" not in captured.out\n        assert \"querying GitHub API\" not in captured.out\n\n    @patch(\"comfy_cli.command.install.git_checkout_tag\", return_value=True)\n    @patch(\"comfy_cli.command.install.get_latest_release\")\n    @patch(\"comfy_cli.command.install._resolve_latest_tag_from_local\", return_value=(None, True))\n    def test_latest_falls_back_to_api_when_local_empty(self, mock_local, mock_api, mock_co):\n        \"\"\"Fetch succeeded but the repo has no stable tags → API fallback runs.\"\"\"\n        mock_api.return_value = {\"tag\": \"v0.20.1\", \"version\": None, \"download_url\": \"u\"}\n\n        checkout_stable_comfyui(\"latest\", \"/repo\")\n\n        mock_local.assert_called_once_with(\"/repo\")\n        mock_api.assert_called_once_with(\"comfyanonymous\", \"ComfyUI\")\n        mock_co.assert_called_once_with(\"/repo\", \"v0.20.1\")\n\n    @patch(\"comfy_cli.command.install.git_checkout_tag\", return_value=True)\n    @patch(\"comfy_cli.command.install.get_latest_release\")\n    @patch(\"comfy_cli.command.install._resolve_latest_tag_from_local\", return_value=(None, True))\n    def test_latest_fallback_uses_fork_owner_repo_from_url(self, mock_local, mock_api, mock_co):\n        \"\"\"Fork case: API fallback must query the FORK's releases/latest, not upstream's.\n\n        Otherwise we'd ask GitHub for `comfyanonymous/ComfyUI`'s latest tag and\n        try to check it out in a fork that may not have it.\n        \"\"\"\n        mock_api.return_value = {\"tag\": \"v0.20.1-myfork\", \"version\": None, \"download_url\": \"u\"}\n\n        checkout_stable_comfyui(\"latest\", \"/repo\", url=\"https://github.com/myfork/ComfyUI\")\n\n        mock_api.assert_called_once_with(\"myfork\", \"ComfyUI\")\n        mock_co.assert_called_once_with(\"/repo\", \"v0.20.1-myfork\")\n\n    @patch(\"comfy_cli.command.install.git_checkout_tag\", return_value=True)\n    @patch(\"comfy_cli.command.install.get_latest_release\")\n    @patch(\"comfy_cli.command.install._resolve_latest_tag_from_local\", return_value=(None, True))\n    def test_latest_fallback_strips_branch_suffix_from_url(self, mock_local, mock_api, mock_co):\n        \"\"\"The setuptools-style `@branch` suffix in the install URL must not leak\n        into the API call. `clone_comfyui` already strips it before cloning.\"\"\"\n        mock_api.return_value = {\"tag\": \"v0.20.1\", \"version\": None, \"download_url\": \"u\"}\n\n        checkout_stable_comfyui(\"latest\", \"/repo\", url=\"https://github.com/myfork/ComfyUI.git@some-branch\")\n\n        mock_api.assert_called_once_with(\"myfork\", \"ComfyUI\")\n\n    @patch(\"comfy_cli.command.install.git_checkout_tag\", return_value=True)\n    @patch(\"comfy_cli.command.install.get_latest_release\")\n    @patch(\"comfy_cli.command.install._resolve_latest_tag_from_local\", return_value=(None, True))\n    def test_latest_fallback_defaults_to_upstream_for_non_github_url(self, mock_local, mock_api, mock_co):\n        \"\"\"Non-GitHub URLs (local paths, GitLab, etc.) fall back to upstream defaults\n        — preserves prior behavior for users whose URL we can't parse.\"\"\"\n        mock_api.return_value = {\"tag\": \"v0.20.1\", \"version\": None, \"download_url\": \"u\"}\n\n        checkout_stable_comfyui(\"latest\", \"/repo\", url=\"/local/path/to/comfyui\")\n\n        mock_api.assert_called_once_with(\"comfyanonymous\", \"ComfyUI\")\n\n    @patch(\"comfy_cli.command.install.git_checkout_tag\", return_value=True)\n    @patch(\"comfy_cli.command.install.get_latest_release\")\n    @patch(\"comfy_cli.command.install._resolve_latest_tag_from_local\", return_value=(None, True))\n    def test_latest_fallback_defaults_to_upstream_when_url_omitted(self, mock_local, mock_api, mock_co):\n        \"\"\"Backward compat: omitting the new `url` kwarg yields the prior behavior\n        (querying upstream).\"\"\"\n        mock_api.return_value = {\"tag\": \"v0.20.1\", \"version\": None, \"download_url\": \"u\"}\n\n        checkout_stable_comfyui(\"latest\", \"/repo\")  # no url=\n\n        mock_api.assert_called_once_with(\"comfyanonymous\", \"ComfyUI\")\n\n    @patch(\"comfy_cli.command.install.git_checkout_tag\", return_value=True)\n    @patch(\"comfy_cli.command.install.get_latest_release\")\n    @patch(\"comfy_cli.command.install._resolve_latest_tag_from_local\", return_value=(None, False))\n    def test_latest_warns_when_fetch_failed_before_api_fallback(self, mock_local, mock_api, mock_co, capsys):\n        \"\"\"When fetch failed AND local has no tags, surface the fetch failure\n        so the user understands why we're falling back to the API.\"\"\"\n        mock_api.return_value = {\"tag\": \"v0.20.1\", \"version\": None, \"download_url\": \"u\"}\n\n        checkout_stable_comfyui(\"latest\", \"/repo\")\n\n        captured = capsys.readouterr()\n        assert \"Could not refresh tags from the remote\" in captured.out\n        # Sanity: didn't print the wrong (success-fetch) branch\n        assert \"No stable release tags found locally\" not in captured.out\n\n    @patch(\"comfy_cli.command.install.git_checkout_tag\", return_value=True)\n    @patch(\"comfy_cli.command.install.get_latest_release\", return_value=None)\n    @patch(\"comfy_cli.command.install._resolve_latest_tag_from_local\", return_value=(None, True))\n    def test_latest_exits_when_both_local_and_api_fail(self, mock_local, mock_api, mock_co):\n        with pytest.raises(SystemExit):\n            checkout_stable_comfyui(\"latest\", \"/repo\")\n        mock_co.assert_not_called()\n\n    @patch(\"comfy_cli.command.install.git_checkout_tag\", return_value=True)\n    @patch(\"comfy_cli.command.install.get_latest_release\")\n    @patch(\"comfy_cli.command.install._resolve_latest_tag_from_local\")\n    def test_specific_version_skips_both_local_and_api(self, mock_local, mock_api, mock_co):\n        \"\"\"`--version 0.20.1` must not consult the API or the local resolver.\"\"\"\n        checkout_stable_comfyui(\"0.20.1\", \"/repo\")\n\n        mock_local.assert_not_called()\n        mock_api.assert_not_called()\n        mock_co.assert_called_once_with(\"/repo\", \"v0.20.1\")\n\n    @patch(\"comfy_cli.command.install.git_checkout_tag\", return_value=True)\n    @patch(\"comfy_cli.command.install.get_latest_release\")\n    @patch(\"comfy_cli.command.install._resolve_latest_tag_from_local\")\n    def test_specific_version_with_v_prefix_passes_through(self, mock_local, mock_api, mock_co):\n        checkout_stable_comfyui(\"v0.20.1\", \"/repo\")\n\n        mock_local.assert_not_called()\n        mock_api.assert_not_called()\n        mock_co.assert_called_once_with(\"/repo\", \"v0.20.1\")\n\n    @patch(\"comfy_cli.command.install.requests.get\")\n    @patch(\"comfy_cli.command.install.git_checkout_tag\", return_value=True)\n    def test_latest_with_rate_limited_api_when_no_local_tags(self, mock_co, mock_get, tmp_path):\n        \"\"\"End-to-end repro of issue #440: empty local clone + 60/hr exhausted IP.\n\n        With no local tags, the resolver returns None and the API path runs;\n        a 403 there must surface as GitHubRateLimitError exactly as before.\n        \"\"\"\n        # Real but tag-less git repo\n        subprocess.run([\"git\", \"init\", \"-q\", str(tmp_path)], check=True)\n\n        rate_limited = Mock()\n        rate_limited.status_code = 403\n        rate_limited.headers = {\"x-ratelimit-remaining\": \"0\", \"x-ratelimit-reset\": \"1777415867\"}\n        mock_get.return_value = rate_limited\n\n        with patch.dict(\"os.environ\", {}, clear=True):\n            with pytest.raises(GitHubRateLimitError, match=\"1777415867\"):\n                checkout_stable_comfyui(\"latest\", str(tmp_path))\n\n        mock_co.assert_not_called()\n\n    @patch(\"comfy_cli.command.install.requests.get\")\n    @patch(\"comfy_cli.command.install.git_checkout_tag\", return_value=True)\n    def test_latest_with_local_tags_no_network_at_all(self, mock_co, mock_get, tmp_path):\n        \"\"\"The pre-fix repro of issue #440: with local tags present, no\n        GitHub API call should be made even when the network is hostile.\"\"\"\n        TestResolveLatestTagFromLocal._make_repo(tmp_path, [\"v0.19.5\", \"v0.20.0\", \"v0.20.1\"])\n\n        with patch.dict(\"os.environ\", {}, clear=True):\n            checkout_stable_comfyui(\"latest\", str(tmp_path))\n\n        # Resolved locally; never touched the API\n        assert mock_get.call_count == 0\n        mock_co.assert_called_once_with(str(tmp_path), \"v0.20.1\")\n\n\nclass TestInstallExecuteWithLatest:\n    \"\"\"Integration test for the FULL `install.execute()` flow with `--version latest`.\n\n    Uses a real (synthetic) git repo on disk so `clone_comfyui`,\n    `_resolve_latest_tag_from_local`, and `git_checkout_tag` actually run.\n    The slow pip / venv steps are mocked. Most importantly, ``requests.get``\n    inside ``install`` is wired to **raise** if invoked — so any future\n    refactor that puts a GitHub API call back on the happy path of\n    ``--version latest`` will fail this test loudly.\n\n    This is the regression net the unit tests can't provide: it proves\n    the clone-then-resolve-then-checkout ordering survives changes to\n    ``execute()``.\n    \"\"\"\n\n    @staticmethod\n    def _make_comfy_repo(path):\n        \"\"\"Build a tag-bearing git repo at `path` that mimics ComfyUI's pattern.\n\n        Each tag points at its own commit so ``git describe --exact-match HEAD``\n        is unambiguous after checkout.\n        \"\"\"\n        subprocess.run([\"git\", \"init\", \"-q\", str(path)], check=True)\n        subprocess.run([\"git\", \"-C\", str(path), \"config\", \"user.email\", \"x@x\"], check=True)\n        subprocess.run([\"git\", \"-C\", str(path), \"config\", \"user.name\", \"x\"], check=True)\n        for tag in [\"v0.18.2\", \"v0.19.5\", \"v0.20.0\", \"v0.20.1\", \"v0.21.0-rc1\"]:\n            subprocess.run(\n                [\"git\", \"-C\", str(path), \"commit\", \"--allow-empty\", \"-m\", f\"release {tag}\", \"-q\"],\n                check=True,\n            )\n            subprocess.run([\"git\", \"-C\", str(path), \"tag\", tag], check=True)\n\n    def test_full_execute_resolves_latest_locally_no_api_call(self, tmp_path, capsys):\n        repo_dir = tmp_path / \"ComfyUI\"\n        self._make_comfy_repo(repo_dir)\n\n        api_calls = []\n\n        def crash_on_api(*args, **kwargs):\n            api_calls.append((\"requests.get\", args, kwargs))\n            raise AssertionError(\n                \"Regression: install.execute('--version latest') made an unexpected \"\n                f\"GitHub API call: args={args}, kwargs={kwargs}\"\n            )\n\n        with (\n            patch.dict(\"os.environ\", {}, clear=True),\n            patch(\"comfy_cli.command.install.requests.get\", side_effect=crash_on_api),\n            patch(\"comfy_cli.command.install.clone_comfyui\") as mock_clone,\n            patch(\"comfy_cli.command.install.ensure_workspace_python\", return_value=sys.executable),\n            patch(\"comfy_cli.command.install.pip_install_comfyui_dependencies\"),\n            patch(\"comfy_cli.command.install.update_node_id_cache\"),\n            patch.object(install_module.workspace_manager, \"skip_prompting\", True),\n            patch.object(install_module.workspace_manager, \"setup_workspace_manager\"),\n            patch(\"comfy_cli.command.install.WorkspaceManager\") as mock_ws_class,\n            patch(\"comfy_cli.config_manager.ConfigManager\") as mock_cfg_class,\n        ):\n            mock_ws_class.return_value = Mock()\n            mock_cfg_class.return_value = Mock()\n\n            install_module.execute(\n                url=\"https://github.com/comfyanonymous/ComfyUI\",\n                comfy_path=str(repo_dir),\n                restore=False,\n                skip_manager=True,\n                version=\"latest\",\n            )\n\n        # The core regression assertions:\n        assert api_calls == [], \"GitHub API was called on the --version latest happy path\"\n        mock_clone.assert_not_called()  # repo already exists at comfy_path\n\n        # The right tag actually got checked out by the real git_checkout_tag call\n        head = subprocess.run(\n            [\"git\", \"-C\", str(repo_dir), \"describe\", \"--tags\", \"--exact-match\", \"HEAD\"],\n            capture_output=True,\n            text=True,\n            check=True,\n        )\n        assert head.stdout.strip() == \"v0.20.1\", (\n            f\"Expected HEAD at v0.20.1 (highest stable tag), got: {head.stdout.strip()!r}\"\n        )\n\n    def test_full_execute_with_specific_version_no_api_no_resolver(self, tmp_path):\n        \"\"\"`--version 0.20.0` must take the direct-tag path, not the resolver.\"\"\"\n        repo_dir = tmp_path / \"ComfyUI\"\n        self._make_comfy_repo(repo_dir)\n\n        with (\n            patch.dict(\"os.environ\", {}, clear=True),\n            patch(\n                \"comfy_cli.command.install.requests.get\",\n                side_effect=AssertionError(\"API must not be called for specific versions\"),\n            ),\n            patch(\n                \"comfy_cli.command.install._resolve_latest_tag_from_local\",\n                side_effect=AssertionError(\"Local resolver must not be called for specific versions\"),\n            ),\n            patch(\"comfy_cli.command.install.clone_comfyui\"),\n            patch(\"comfy_cli.command.install.ensure_workspace_python\", return_value=sys.executable),\n            patch(\"comfy_cli.command.install.pip_install_comfyui_dependencies\"),\n            patch(\"comfy_cli.command.install.update_node_id_cache\"),\n            patch.object(install_module.workspace_manager, \"skip_prompting\", True),\n            patch.object(install_module.workspace_manager, \"setup_workspace_manager\"),\n            patch(\"comfy_cli.command.install.WorkspaceManager\") as mock_ws_class,\n            patch(\"comfy_cli.config_manager.ConfigManager\") as mock_cfg_class,\n        ):\n            mock_ws_class.return_value = Mock()\n            mock_cfg_class.return_value = Mock()\n\n            install_module.execute(\n                url=\"https://github.com/comfyanonymous/ComfyUI\",\n                comfy_path=str(repo_dir),\n                restore=False,\n                skip_manager=True,\n                version=\"0.20.0\",\n            )\n\n        head = subprocess.run(\n            [\"git\", \"-C\", str(repo_dir), \"describe\", \"--tags\", \"--exact-match\", \"HEAD\"],\n            capture_output=True,\n            text=True,\n            check=True,\n        )\n        assert head.stdout.strip() == \"v0.20.0\"\n\n\nif __name__ == \"__main__\":\n    pytest.main([__file__])\n"
  },
  {
    "path": "tests/comfy_cli/command/models/test_models.py",
    "content": "import pathlib\nfrom unittest.mock import Mock, patch\n\nimport typer.testing\n\nfrom comfy_cli import constants\nfrom comfy_cli.command.models.models import _format_elapsed, app, check_civitai_url, check_huggingface_url, list_models\n\n\ndef _make_model_tree(tmp_path: pathlib.Path) -> pathlib.Path:\n    \"\"\"Create a realistic model directory tree and return its root.\"\"\"\n    model_dir = tmp_path / \"models\"\n    (model_dir / \"root_model.safetensors\").parent.mkdir(parents=True, exist_ok=True)\n    (model_dir / \"root_model.safetensors\").write_bytes(b\"x\" * 100)\n    (model_dir / \"checkpoints\").mkdir()\n    (model_dir / \"checkpoints\" / \"sd15.safetensors\").write_bytes(b\"x\" * 200)\n    (model_dir / \"loras\" / \"SD1.5\").mkdir(parents=True)\n    (model_dir / \"loras\" / \"SD1.5\" / \"detail.safetensors\").write_bytes(b\"x\" * 300)\n    (model_dir / \"empty_dir\").mkdir()\n    return model_dir\n\n\ndef test_list_models_finds_files_in_subdirectories(tmp_path):\n    model_dir = _make_model_tree(tmp_path)\n    result = list_models(model_dir)\n    names = {f.name for f in result}\n    assert \"sd15.safetensors\" in names\n    deep = [f for f in result if f.name == \"detail.safetensors\"]\n    assert len(deep) == 1\n    assert deep[0].relative_to(model_dir) == pathlib.Path(\"loras/SD1.5/detail.safetensors\")\n\n\ndef test_list_models_finds_root_level_files(tmp_path):\n    model_dir = _make_model_tree(tmp_path)\n    result = list_models(model_dir)\n    names = {f.name for f in result}\n    assert \"root_model.safetensors\" in names\n\n\ndef test_list_models_returns_empty_for_missing_directory(tmp_path):\n    assert list_models(tmp_path / \"nonexistent\") == []\n\n\ndef test_list_models_ignores_directories(tmp_path):\n    model_dir = _make_model_tree(tmp_path)\n    result = list_models(model_dir)\n    assert all(f.is_file() for f in result)\n    dir_names = {f.name for f in result}\n    assert \"empty_dir\" not in dir_names\n    assert \"checkpoints\" not in dir_names\n\n\nrunner = typer.testing.CliRunner()\n\n\ndef test_list_command_shows_type_column(tmp_path):\n    _make_model_tree(tmp_path)\n    with patch(\"comfy_cli.command.models.models.get_workspace\", return_value=tmp_path):\n        result = runner.invoke(app, [\"list\", \"--relative-path\", \"models\"])\n    assert result.exit_code == 0\n    assert \"Type\" in result.output\n    assert \"checkpoints\" in result.output\n    assert \"loras/SD1.5\" in result.output\n    assert \"root_model.safetensors\" in result.output\n\n\ndef test_remove_with_path_traversal_is_rejected(tmp_path):\n    model_dir = tmp_path / \"models\"\n    model_dir.mkdir()\n    (model_dir / \"legit.bin\").write_bytes(b\"x\")\n\n    secret = tmp_path / \"secret.txt\"\n    secret.write_text(\"sensitive\")\n\n    with patch(\"comfy_cli.command.models.models.get_workspace\", return_value=tmp_path):\n        result = runner.invoke(\n            app,\n            [\"remove\", \"--relative-path\", \"models\", \"--model-names\", \"../secret.txt\", \"--confirm\"],\n        )\n    assert secret.exists()\n    assert \"Invalid model path\" in result.output\n\n\ndef test_remove_deletes_model_in_subdirectory(tmp_path):\n    model_dir = _make_model_tree(tmp_path)\n    target = model_dir / \"checkpoints\" / \"sd15.safetensors\"\n    assert target.exists()\n\n    with patch(\"comfy_cli.command.models.models.get_workspace\", return_value=tmp_path):\n        result = runner.invoke(\n            app,\n            [\"remove\", \"--relative-path\", \"models\", \"--model-names\", \"checkpoints/sd15.safetensors\", \"--confirm\"],\n        )\n    assert result.exit_code == 0\n    assert not target.exists()\n\n\ndef test_remove_rejects_directory_name(tmp_path):\n    _make_model_tree(tmp_path)\n\n    with patch(\"comfy_cli.command.models.models.get_workspace\", return_value=tmp_path):\n        result = runner.invoke(\n            app,\n            [\"remove\", \"--relative-path\", \"models\", \"--model-names\", \"checkpoints\", \"--confirm\"],\n        )\n    assert (tmp_path / \"models\" / \"checkpoints\").is_dir()\n    assert \"not found\" in result.output\n\n\ndef test_remove_deletes_root_level_model(tmp_path):\n    model_dir = _make_model_tree(tmp_path)\n    target = model_dir / \"root_model.safetensors\"\n    assert target.exists()\n\n    with patch(\"comfy_cli.command.models.models.get_workspace\", return_value=tmp_path):\n        result = runner.invoke(\n            app,\n            [\"remove\", \"--relative-path\", \"models\", \"--model-names\", \"root_model.safetensors\", \"--confirm\"],\n        )\n    assert result.exit_code == 0\n    assert not target.exists()\n\n\ndef test_remove_interactive_shows_relative_paths(tmp_path):\n    _make_model_tree(tmp_path)\n\n    with (\n        patch(\"comfy_cli.command.models.models.get_workspace\", return_value=tmp_path),\n        patch(\"comfy_cli.command.models.models.ui\") as mock_ui,\n    ):\n        mock_ui.prompt_multi_select.return_value = [\"checkpoints/sd15.safetensors\"]\n        mock_ui.prompt_confirm_action.return_value = True\n        runner.invoke(app, [\"remove\", \"--relative-path\", \"models\"])\n\n    choices = mock_ui.prompt_multi_select.call_args[0][1]\n    assert \"checkpoints/sd15.safetensors\" in choices\n    assert \"loras/SD1.5/detail.safetensors\" in choices\n    assert \"root_model.safetensors\" in choices\n    assert not (tmp_path / \"models\" / \"checkpoints\" / \"sd15.safetensors\").exists()\n\n\ndef test_valid_model_url():\n    url = \"https://civitai.com/models/43331\"\n    assert check_civitai_url(url) == (True, False, 43331, None)\n\n\ndef test_valid_model_url_with_version():\n    url = \"https://civitai.com/models/43331/majicmix-realistic\"\n    assert check_civitai_url(url) == (True, False, 43331, None)\n\n\ndef test_valid_model_url_with_version_and_additional_segments():\n    url = \"https://civitai.com/models/43331/majicmix-realistic/extra\"\n    assert check_civitai_url(url) == (True, False, 43331, None)\n\n\ndef test_valid_model_url_with_query():\n    url = \"https://civitai.com/models/43331?version=12345\"\n    assert check_civitai_url(url) == (True, False, 43331, 12345)\n\n\ndef test_valid_api_url():\n    url = \"https://civitai.com/api/download/models/67890\"\n    assert check_civitai_url(url) == (False, True, None, 67890)\n\n\ndef test_invalid_url():\n    url = \"https://example.com/models/43331\"\n    assert check_civitai_url(url) == (False, False, None, None)\n\n\ndef test_malformed_url():\n    url = \"https://civitai.com/models/\"\n    assert check_civitai_url(url) == (False, False, None, None)\n\n\ndef test_invalid_model_id_url():\n    url = \"https://civitai.com/models/invalid_id\"\n    assert check_civitai_url(url) == (False, False, None, None)\n\n\ndef test_malformed_query_url():\n    url = \"https://civitai.com/models/43331?version=\"\n    assert check_civitai_url(url) == (True, False, 43331, None)\n\n\ndef test_model_url_with_model_version_id_query():\n    url = \"https://civitai.com/models/43331?modelVersionId=485088\"\n    assert check_civitai_url(url) == (True, False, 43331, 485088)\n\n\ndef test_model_url_with_model_version_id_invalid():\n    url = \"https://civitai.com/models/43331?modelVersionId=abc\"\n    assert check_civitai_url(url) == (True, False, 43331, None)\n\n\ndef test_valid_api_v1_model_versions_url():\n    url = \"https://civitai.com/api/v1/model-versions/1617665\"\n    assert check_civitai_url(url) == (False, True, None, 1617665)\n\n\ndef test_valid_api_v1_model_versions_camelcase_segment():\n    url = \"https://civitai.com/api/v1/modelVersions/1617665\"\n    assert check_civitai_url(url) == (False, True, None, 1617665)\n\n\ndef test_valid_api_download_with_query_params():\n    url = \"https://civitai.com/api/download/models/1617665?type=Model&format=SafeTensor\"\n    assert check_civitai_url(url) == (False, True, None, 1617665)\n\n\ndef test_api_download_trailing_slash_is_ok():\n    url = \"https://civitai.com/api/download/models/1617665/\"\n    assert check_civitai_url(url) == (False, True, None, 1617665)\n\n\ndef test_api_download_non_numeric_id_models_version():\n    url = \"https://civitai.com/api/v1/modelVersions/notanumber\"\n    assert check_civitai_url(url) == (False, True, None, None)\n\n\ndef test_api_download_non_numeric_id():\n    url = \"https://civitai.com/api/download/models/notanumber\"\n    assert check_civitai_url(url) == (False, True, None, None)\n\n\ndef test_model_url_with_slug_and_query():\n    url = \"https://civitai.com/models/43331/majicmix-realistic?modelVersionId=485088\"\n    assert check_civitai_url(url) == (True, False, 43331, 485088)\n\n\ndef test_www_subdomain_is_accepted():\n    url = \"https://www.civitai.com/models/43331?version=12345\"\n    assert check_civitai_url(url) == (True, False, 43331, 12345)\n\n\ndef test_completly_mailformed_civitai_url():\n    url = \"https://civitai.com/\"\n    assert check_civitai_url(url) == (False, False, None, None)\n\n\ndef test_non_evil_civitai_url():\n    url = \"https://evilcivitai.com/models/43331?version=12345\"\n    assert check_civitai_url(url) == (False, False, None, None)\n\n\ndef test_valid_model_url_red_domain():\n    url = \"https://civitai.red/models/43331\"\n    assert check_civitai_url(url) == (True, False, 43331, None)\n\n\ndef test_valid_model_url_red_with_query():\n    url = \"https://civitai.red/models/43331?modelVersionId=485088\"\n    assert check_civitai_url(url) == (True, False, 43331, 485088)\n\n\ndef test_valid_api_download_url_red_domain():\n    url = \"https://civitai.red/api/download/models/1617665?type=Model&format=SafeTensor\"\n    assert check_civitai_url(url) == (False, True, None, 1617665)\n\n\ndef test_valid_api_v1_model_versions_url_red_domain():\n    url = \"https://civitai.red/api/v1/model-versions/1617665\"\n    assert check_civitai_url(url) == (False, True, None, 1617665)\n\n\ndef test_www_subdomain_red_is_accepted():\n    url = \"https://www.civitai.red/models/43331?version=12345\"\n    assert check_civitai_url(url) == (True, False, 43331, 12345)\n\n\ndef test_non_evil_civitai_red_url():\n    url = \"https://evilcivitai.red/models/43331?version=12345\"\n    assert check_civitai_url(url) == (False, False, None, None)\n\n\ndef test_red_as_spoofed_subdomain_of_other_tld():\n    url = \"https://civitai.red.evil.com/models/43331\"\n    assert check_civitai_url(url) == (False, False, None, None)\n\n\ndef test_valid_huggingface_url():\n    url = \"https://huggingface.co/CompVis/stable-diffusion-v1-4/resolve/main/sd-v1-4.ckpt\"\n    assert check_huggingface_url(url) == (True, \"CompVis/stable-diffusion-v1-4\", \"sd-v1-4.ckpt\", None, \"main\")\n\n\ndef test_valid_huggingface_url_sd_audio():\n    url = \"https://huggingface.co/stabilityai/stable-audio-open-1.0/blob/main/model.safetensors\"\n    assert check_huggingface_url(url) == (True, \"stabilityai/stable-audio-open-1.0\", \"model.safetensors\", None, \"main\")\n\n\ndef test_valid_huggingface_url_with_folder():\n    url = \"https://huggingface.co/runwayml/stable-diffusion-v1-5/resolve/main/v1-5-pruned-emaonly.ckpt\"\n    assert check_huggingface_url(url) == (\n        True,\n        \"runwayml/stable-diffusion-v1-5\",\n        \"v1-5-pruned-emaonly.ckpt\",\n        None,\n        \"main\",\n    )\n\n\ndef test_valid_huggingface_url_with_subfolder():\n    url = \"https://huggingface.co/stabilityai/stable-diffusion-2-1/resolve/main/v2-1_768-ema-pruned.ckpt\"\n    assert check_huggingface_url(url) == (\n        True,\n        \"stabilityai/stable-diffusion-2-1\",\n        \"v2-1_768-ema-pruned.ckpt\",\n        None,\n        \"main\",\n    )\n\n\ndef test_valid_huggingface_url_with_encoded_filename():\n    url = \"https://huggingface.co/CompVis/stable-diffusion-v1-4/resolve/main/sd-v1-4%20(1).ckpt\"\n    assert check_huggingface_url(url) == (True, \"CompVis/stable-diffusion-v1-4\", \"sd-v1-4 (1).ckpt\", None, \"main\")\n\n\ndef test_invalid_huggingface_url():\n    url = \"https://example.com/CompVis/stable-diffusion-v1-4/resolve/main/sd-v1-4.ckpt\"\n    assert check_huggingface_url(url) == (False, None, None, None, None)\n\n\ndef test_invalid_huggingface_url_structure():\n    url = \"https://huggingface.co/CompVis/stable-diffusion-v1-4/main/sd-v1-4.ckpt\"\n    assert check_huggingface_url(url) == (False, None, None, None, None)\n\n\ndef test_huggingface_url_with_com_domain():\n    url = \"https://huggingface.com/CompVis/stable-diffusion-v1-4/resolve/main/sd-v1-4.ckpt\"\n    assert check_huggingface_url(url) == (True, \"CompVis/stable-diffusion-v1-4\", \"sd-v1-4.ckpt\", None, \"main\")\n\n\ndef test_huggingface_url_with_folder_structure():\n    url = \"https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors\"\n    assert check_huggingface_url(url) == (\n        True,\n        \"stabilityai/stable-diffusion-xl-base-1.0\",\n        \"sd_xl_base_1.0.safetensors\",\n        None,\n        \"main\",\n    )\n\n\nclass TestFormatElapsed:\n    def test_under_one_minute(self):\n        assert _format_elapsed(5.3) == \"5.3s\"\n\n    def test_fractional_seconds(self):\n        assert _format_elapsed(0.4) == \"0.4s\"\n\n    def test_rounds_up_to_minute_boundary(self):\n        assert _format_elapsed(59.95) == \"1m 0s\"\n\n    def test_exactly_sixty_seconds(self):\n        assert _format_elapsed(60) == \"1m 0s\"\n\n    def test_minutes_and_seconds(self):\n        assert _format_elapsed(154) == \"2m 34s\"\n\n    def test_over_one_hour(self):\n        assert _format_elapsed(3661) == \"1h 1m 1s\"\n\n    def test_large_duration(self):\n        assert _format_elapsed(7384) == \"2h 3m 4s\"\n\n\n# ---------------------------------------------------------------------------\n# --downloader CLI option tests\n# ---------------------------------------------------------------------------\n\n\nclass TestDownloadCommandDownloaderOption:\n    def test_downloader_flag_forwarded(self, tmp_path):\n        \"\"\"--downloader aria2 flag is forwarded to download_file.\"\"\"\n        with (\n            patch(\"comfy_cli.command.models.models.get_workspace\", return_value=tmp_path),\n            patch(\"comfy_cli.command.models.models.download_file\") as mock_dl,\n            patch(\"comfy_cli.command.models.models.check_civitai_url\", return_value=(False, False, None, None)),\n            patch(\n                \"comfy_cli.command.models.models.check_huggingface_url\", return_value=(False, None, None, None, None)\n            ),\n            patch(\"comfy_cli.command.models.models.ui\") as mock_ui,\n            patch(\"comfy_cli.command.models.models.config_manager\"),\n            patch(\"comfy_cli.tracking.track_command\", lambda _cmd: lambda fn: fn),\n        ):\n            mock_ui.prompt_input.side_effect = [\"mymodel.bin\", \"\"]\n            result = runner.invoke(\n                app,\n                [\n                    \"download\",\n                    \"--url\",\n                    \"http://example.com/model.bin\",\n                    \"--downloader\",\n                    \"aria2\",\n                    \"--filename\",\n                    \"model.bin\",\n                ],\n            )\n\n            assert mock_dl.called\n            _, kwargs = mock_dl.call_args\n            assert kwargs.get(\"downloader\") == \"aria2\"\n            assert \"Done in \" in result.output\n\n    def test_default_from_config(self, tmp_path):\n        \"\"\"Config default_downloader is used when no --downloader flag.\"\"\"\n        mock_cfg = Mock()\n        mock_cfg.get_or_override.return_value = None\n        mock_cfg.get.side_effect = lambda key: \"aria2\" if key == constants.CONFIG_KEY_DEFAULT_DOWNLOADER else None\n\n        with (\n            patch(\"comfy_cli.command.models.models.get_workspace\", return_value=tmp_path),\n            patch(\"comfy_cli.command.models.models.download_file\") as mock_dl,\n            patch(\"comfy_cli.command.models.models.check_civitai_url\", return_value=(False, False, None, None)),\n            patch(\n                \"comfy_cli.command.models.models.check_huggingface_url\", return_value=(False, None, None, None, None)\n            ),\n            patch(\"comfy_cli.command.models.models.ui\") as mock_ui,\n            patch(\"comfy_cli.command.models.models.config_manager\", mock_cfg),\n            patch(\"comfy_cli.tracking.track_command\", lambda _cmd: lambda fn: fn),\n        ):\n            mock_ui.prompt_input.side_effect = [\"mymodel.bin\", \"\"]\n            runner.invoke(\n                app,\n                [\n                    \"download\",\n                    \"--url\",\n                    \"http://example.com/model.bin\",\n                    \"--filename\",\n                    \"model.bin\",\n                ],\n            )\n\n            assert mock_dl.called\n            _, kwargs = mock_dl.call_args\n            assert kwargs.get(\"downloader\") == \"aria2\"\n\n    def test_cli_flag_overrides_config(self, tmp_path):\n        \"\"\"CLI --downloader flag takes precedence over config.\"\"\"\n        mock_cfg = Mock()\n        mock_cfg.get_or_override.return_value = None\n        mock_cfg.get.side_effect = lambda key: \"aria2\" if key == constants.CONFIG_KEY_DEFAULT_DOWNLOADER else None\n\n        with (\n            patch(\"comfy_cli.command.models.models.get_workspace\", return_value=tmp_path),\n            patch(\"comfy_cli.command.models.models.download_file\") as mock_dl,\n            patch(\"comfy_cli.command.models.models.check_civitai_url\", return_value=(False, False, None, None)),\n            patch(\n                \"comfy_cli.command.models.models.check_huggingface_url\", return_value=(False, None, None, None, None)\n            ),\n            patch(\"comfy_cli.command.models.models.ui\") as mock_ui,\n            patch(\"comfy_cli.command.models.models.config_manager\", mock_cfg),\n            patch(\"comfy_cli.tracking.track_command\", lambda _cmd: lambda fn: fn),\n        ):\n            mock_ui.prompt_input.side_effect = [\"mymodel.bin\", \"\"]\n            runner.invoke(\n                app,\n                [\n                    \"download\",\n                    \"--url\",\n                    \"http://example.com/model.bin\",\n                    \"--downloader\",\n                    \"httpx\",\n                    \"--filename\",\n                    \"model.bin\",\n                ],\n            )\n\n            assert mock_dl.called\n            _, kwargs = mock_dl.call_args\n            assert kwargs.get(\"downloader\") == \"httpx\"\n\n\nclass TestDownloadCommandErrorHandling:\n    \"\"\"Verify DownloadException is rendered as a friendly one-line error (not a traceback).\"\"\"\n\n    def _run_with_download_error(self, tmp_path, exc):\n        with (\n            patch(\"comfy_cli.command.models.models.get_workspace\", return_value=tmp_path),\n            patch(\"comfy_cli.command.models.models.download_file\", side_effect=exc),\n            patch(\"comfy_cli.command.models.models.check_civitai_url\", return_value=(False, False, None, None)),\n            patch(\n                \"comfy_cli.command.models.models.check_huggingface_url\",\n                return_value=(False, None, None, None, None),\n            ),\n            patch(\"comfy_cli.command.models.models.ui\") as mock_ui,\n            patch(\"comfy_cli.command.models.models.config_manager\"),\n            patch(\"comfy_cli.tracking.track_command\", lambda _cmd: lambda fn: fn),\n        ):\n            mock_ui.prompt_input.side_effect = [\"mymodel.bin\", \"\"]\n            return runner.invoke(\n                app,\n                [\n                    \"download\",\n                    \"--url\",\n                    \"http://example.com/model.bin\",\n                    \"--filename\",\n                    \"model.bin\",\n                ],\n            )\n\n    def test_download_exception_exits_with_code_1(self, tmp_path):\n        from comfy_cli.file_utils import DownloadException\n\n        result = self._run_with_download_error(tmp_path, DownloadException(\"boom\"))\n\n        assert result.exit_code == 1\n        assert \"boom\" in result.output\n\n    def test_download_exception_does_not_show_traceback(self, tmp_path):\n        from comfy_cli.file_utils import DownloadException\n\n        result = self._run_with_download_error(tmp_path, DownloadException(\"boom\"))\n\n        assert \"Traceback\" not in result.output\n        assert \"DownloadException\" not in result.output\n        # Rich markup must be rendered as styling, not leak through as literal tags.\n        assert \"[bold red]\" not in result.output\n        assert \"[/bold red]\" not in result.output\n\n    def test_download_exception_skips_done_message(self, tmp_path):\n        from comfy_cli.file_utils import DownloadException\n\n        result = self._run_with_download_error(tmp_path, DownloadException(\"boom\"))\n\n        assert \"Done in\" not in result.output\n\n    def test_download_exception_with_markup_chars_does_not_crash(self, tmp_path):\n        \"\"\"A DownloadException message containing rich-markup metacharacters (e.g. from a\n        server JSON body embedded via guess_status_code_reason) must not raise MarkupError\n        nor be silently stripped — the error must render literally and exit cleanly.\"\"\"\n        from comfy_cli.file_utils import DownloadException\n\n        # Covers both the closing-tag crash case and the bracketed-style stripping case.\n        result = self._run_with_download_error(tmp_path, DownloadException(\"server said [/] at /path/[id]/resource\"))\n\n        assert result.exit_code == 1\n        assert \"Traceback\" not in result.output\n        assert \"MarkupError\" not in result.output\n        # Literal markup characters must survive to the output so the user sees the real message.\n        assert \"[/]\" in result.output\n        assert \"[id]\" in result.output\n"
  },
  {
    "path": "tests/comfy_cli/command/nodes/test_bisect_custom_nodes.py",
    "content": "import json\nfrom unittest.mock import patch\n\nimport pytest\n\nfrom comfy_cli.command.custom_nodes.bisect_custom_nodes import BisectState\n\n\n@pytest.fixture(scope=\"function\")\ndef bisect_state():\n    return BisectState(\n        status=\"running\",\n        all=[\"node1\", \"node2\", \"node3\"],\n        range=[\"node1\", \"node2\", \"node3\"],\n        active=[\"node1\", \"node2\"],\n    )\n\n\ndef test_good():\n    bisect_state = BisectState(\n        status=\"running\",\n        all=[\"node1\", \"node2\", \"node3\"],\n        range=[\"node1\", \"node2\", \"node3\"],\n        active=[\"node1\"],\n    )\n    new_state = bisect_state.good()\n    assert new_state.status == \"running\"\n    assert new_state.all == bisect_state.all\n    assert set(new_state.range) == set([\"node3\", \"node2\"])\n    assert len(new_state.active) == 1\n\n\ndef test_good_resolved(bisect_state: BisectState):\n    new_state = bisect_state.good()\n    assert new_state.status == \"resolved\"\n    assert new_state.all == bisect_state.all\n    assert new_state.range == [\"node3\"]\n    assert new_state.active == []\n\n\ndef test_bad(bisect_state):\n    new_state = bisect_state.bad()\n    assert new_state.status == \"running\"\n    assert new_state.all == bisect_state.all\n    assert new_state.range == [\"node1\", \"node2\"]\n    assert new_state.active == [\"node2\"]\n\n\ndef test_bad_resolved():\n    bisect_state = BisectState(\n        status=\"running\",\n        all=[\"node1\", \"node2\", \"node3\"],\n        range=[\"node1\", \"node2\", \"node3\"],\n        active=[\"node1\"],\n    )\n    new_state = bisect_state.bad()\n    assert new_state.status == \"resolved\"\n    assert new_state.all == bisect_state.all\n    assert new_state.range == [\"node1\"]\n    assert new_state.active == []\n\n\n@patch(\"comfy_cli.command.custom_nodes.bisect_custom_nodes.execute_cm_cli\")\ndef test_save(mock_execute_cm_cli, bisect_state, tmp_path):\n    state_file = tmp_path / \"bisect_state.json\"\n    bisect_state.save(state_file)\n    assert state_file.exists()\n    assert mock_execute_cm_cli.call_count == 2\n    with state_file.open() as f:\n        saved_state = json.load(f)\n    assert saved_state == bisect_state._asdict()\n\n\n@patch(\"comfy_cli.command.custom_nodes.bisect_custom_nodes.execute_cm_cli\")\ndef test_reset(mock_execute_cm_cli, bisect_state):\n    new_state = bisect_state.reset()\n    assert new_state.status == \"idle\"\n    assert new_state.all == [\"node1\", \"node2\", \"node3\"]\n    assert new_state.range == [\"node1\", \"node2\", \"node3\"]\n    assert new_state.active == [\"node1\", \"node2\", \"node3\"]\n    assert mock_execute_cm_cli.call_count == 1\n\n\ndef test_load_existing_state(tmp_path):\n    state_file = tmp_path / \"bisect_state.json\"\n    state_data = {\n        \"status\": \"running\",\n        \"all\": [\"node1\", \"node2\", \"node3\"],\n        \"range\": [\"node1\", \"node2\", \"node3\"],\n        \"active\": [\"node1\", \"node2\"],\n    }\n    with state_file.open(\"w\") as f:\n        json.dump(state_data, f)\n\n    loaded_state = BisectState.load(state_file)\n    assert loaded_state.status == state_data[\"status\"]\n    assert loaded_state.all == state_data[\"all\"]\n    assert loaded_state.range == state_data[\"range\"]\n    assert loaded_state.active == state_data[\"active\"]\n\n\ndef test_load_nonexistent_state(tmp_path):\n    state_file = tmp_path / \"bisect_state.json\"\n    loaded_state = BisectState.load(state_file)\n    assert loaded_state.status == \"idle\"\n    assert loaded_state.all == []\n    assert loaded_state.range == []\n    assert loaded_state.active == []\n\n\n@patch(\"comfy_cli.command.custom_nodes.bisect_custom_nodes.execute_cm_cli\")\ndef test_set_custom_node_enabled_states(mock_execute_cm_cli, bisect_state):\n    bisect_state.set_custom_node_enabled_states()\n    assert mock_execute_cm_cli.call_count == 2\n\n\n@patch(\"comfy_cli.command.custom_nodes.bisect_custom_nodes.execute_cm_cli\")\ndef test_set_custom_node_enabled_states_no_active_nodes(mock_execute_cm_cli):\n    bisect_state = BisectState(\n        status=\"running\",\n        all=[\"node1\", \"node2\", \"node3\"],\n        range=[\"node1\", \"node2\", \"node3\"],\n        active=[],\n    )\n    bisect_state.set_custom_node_enabled_states()\n    assert mock_execute_cm_cli.call_count == 1\n"
  },
  {
    "path": "tests/comfy_cli/command/nodes/test_node_init.py",
    "content": "import subprocess\n\nimport tomlkit\nfrom typer.testing import CliRunner\n\nfrom comfy_cli.command.custom_nodes.command import app\n\nrunner = CliRunner()\n\n\ndef test_node_init_strips_credentials(tmp_path, monkeypatch):\n    monkeypatch.chdir(tmp_path)\n    subprocess.run([\"git\", \"init\"], cwd=tmp_path, check=True, capture_output=True)\n    subprocess.run(\n        [\"git\", \"remote\", \"add\", \"origin\", \"https://ghp_FAKESECRET123@github.com/user/ComfyUI-TestNode.git\"],\n        cwd=tmp_path,\n        check=True,\n        capture_output=True,\n    )\n    (tmp_path / \"requirements.txt\").write_text(\"requests\\n\")\n\n    result = runner.invoke(app, [\"init\"])\n\n    assert result.exit_code == 0\n    with open(tmp_path / \"pyproject.toml\") as f:\n        data = tomlkit.parse(f.read())\n    raw = tomlkit.dumps(data)\n    assert \"ghp_FAKESECRET123\" not in raw\n    assert data[\"project\"][\"urls\"][\"Repository\"] == \"https://github.com/user/ComfyUI-TestNode\"\n\n\ndef test_node_init_refuses_overwrite(tmp_path, monkeypatch):\n    monkeypatch.chdir(tmp_path)\n    (tmp_path / \"pyproject.toml\").write_text(\"[project]\\n\")\n\n    result = runner.invoke(app, [\"init\"])\n\n    assert result.exit_code == 1\n    assert \"already exists\" in result.stdout\n"
  },
  {
    "path": "tests/comfy_cli/command/nodes/test_node_install.py",
    "content": "import re\nimport subprocess\nfrom unittest.mock import MagicMock, patch\n\nfrom typer.testing import CliRunner\n\nfrom comfy_cli.command.custom_nodes.command import app\nfrom comfy_cli.file_utils import DownloadException\n\nrunner = CliRunner()\n\n\ndef strip_ansi(text):\n    ansi_escape = re.compile(r\"\\x1B(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~])\")\n    return ansi_escape.sub(\"\", text)\n\n\ndef test_install_no_deps_option_exists():\n    result = runner.invoke(app, [\"install\", \"--help\"])\n    assert result.exit_code == 0\n    clean_output = strip_ansi(result.stdout)\n    assert \"--no-deps\" in clean_output\n    assert \"Skip dependency installation\" in clean_output\n\n\ndef test_install_fast_deps_and_no_deps_mutually_exclusive():\n    result = runner.invoke(app, [\"install\", \"test-node\", \"--fast-deps\", \"--no-deps\"])\n    assert result.exit_code != 0\n    assert \"Cannot use --fast-deps and --no-deps together\" in result.output\n\n\ndef test_install_no_deps_alone_works():\n    with patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\") as mock_execute:\n        result = runner.invoke(app, [\"install\", \"test-node\", \"--no-deps\"])\n        assert result.exit_code == 0\n        mock_execute.assert_called_once()\n        _, kwargs = mock_execute.call_args\n        assert kwargs.get(\"no_deps\") is True\n        assert kwargs.get(\"fast_deps\") is False\n\n\ndef test_install_fast_deps_alone_works():\n    with patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\") as mock_execute:\n        result = runner.invoke(app, [\"install\", \"test-node\", \"--fast-deps\"])\n        assert result.exit_code == 0\n        mock_execute.assert_called_once()\n        _, kwargs = mock_execute.call_args\n        assert kwargs.get(\"fast_deps\") is True\n        assert kwargs.get(\"no_deps\") is False\n\n\ndef test_install_neither_deps_option():\n    with patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\") as mock_execute:\n        result = runner.invoke(app, [\"install\", \"test-node\"])\n        assert result.exit_code == 0\n        mock_execute.assert_called_once()\n        _, kwargs = mock_execute.call_args\n        assert kwargs.get(\"fast_deps\") is False\n        assert kwargs.get(\"no_deps\") is False\n\n\ndef test_multiple_commands_work_independently():\n    with patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\"):\n        result1 = runner.invoke(app, [\"install\", \"test-node\", \"--no-deps\"])\n        assert result1.exit_code == 0\n\n    with patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\"):\n        result2 = runner.invoke(app, [\"install\", \"test-node2\", \"--fast-deps\"])\n        assert result2.exit_code == 0\n\n\ndef test_install_uv_compile_passes_to_execute():\n    with patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\") as mock_execute:\n        result = runner.invoke(app, [\"install\", \"test-node\", \"--uv-compile\"])\n        assert result.exit_code == 0\n        mock_execute.assert_called_once()\n        _, kwargs = mock_execute.call_args\n        assert kwargs.get(\"uv_compile\") is True\n        assert kwargs.get(\"fast_deps\") is False\n        assert kwargs.get(\"no_deps\") is False\n\n\ndef test_install_no_uv_compile_passes_false():\n    with patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\") as mock_execute:\n        result = runner.invoke(app, [\"install\", \"test-node\", \"--no-uv-compile\"])\n        assert result.exit_code == 0\n        mock_execute.assert_called_once()\n        _, kwargs = mock_execute.call_args\n        assert kwargs.get(\"uv_compile\") is False\n\n\ndef test_install_uv_compile_and_fast_deps_mutually_exclusive():\n    result = runner.invoke(app, [\"install\", \"test-node\", \"--uv-compile\", \"--fast-deps\"])\n    assert result.exit_code != 0\n    assert \"Cannot use\" in result.output\n\n\ndef test_install_uv_compile_and_no_deps_mutually_exclusive():\n    result = runner.invoke(app, [\"install\", \"test-node\", \"--uv-compile\", \"--no-deps\"])\n    assert result.exit_code != 0\n    assert \"Cannot use\" in result.output\n\n\ndef test_uv_sync_calls_execute_cm_cli():\n    with patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\") as mock_execute:\n        result = runner.invoke(app, [\"uv-sync\"])\n        assert result.exit_code == 0\n        mock_execute.assert_called_once()\n        args, _ = mock_execute.call_args\n        assert args[0] == [\"uv-sync\"]\n\n\ndef test_reinstall_uv_compile_passes_to_execute():\n    with patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\") as mock_execute:\n        result = runner.invoke(app, [\"reinstall\", \"test-node\", \"--uv-compile\"])\n        assert result.exit_code == 0\n        mock_execute.assert_called_once()\n        _, kwargs = mock_execute.call_args\n        assert kwargs.get(\"uv_compile\") is True\n\n\ndef test_reinstall_uv_compile_and_fast_deps_mutually_exclusive():\n    result = runner.invoke(app, [\"reinstall\", \"test-node\", \"--uv-compile\", \"--fast-deps\"])\n    assert result.exit_code != 0\n    assert \"Cannot use\" in result.output\n\n\ndef test_reinstall_no_uv_compile_passes_false():\n    with patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\") as mock_execute:\n        result = runner.invoke(app, [\"reinstall\", \"test-node\", \"--no-uv-compile\"])\n        assert result.exit_code == 0\n        mock_execute.assert_called_once()\n        _, kwargs = mock_execute.call_args\n        assert kwargs.get(\"uv_compile\") is False\n\n\ndef test_install_exit_on_fail_reraises_and_propagates_code():\n    with patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\") as mock_execute:\n        mock_execute.side_effect = subprocess.CalledProcessError(7, \"cm-cli\")\n        result = runner.invoke(app, [\"install\", \"bad-node\", \"--exit-on-fail\"])\n        assert result.exit_code == 7\n        assert mock_execute.called\n        args, kwargs = mock_execute.call_args\n        assert kwargs.get(\"raise_on_error\") is True\n        assert args[0][0] == \"install\" and \"--exit-on-fail\" in args[0] and \"bad-node\" in args[0]\n\n\ndef test_save_snapshot_no_output():\n    with patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\") as mock_execute:\n        result = runner.invoke(app, [\"save-snapshot\"])\n        assert result.exit_code == 0\n        mock_execute.assert_called_once()\n        args, _ = mock_execute.call_args\n        assert args[0] == [\"save-snapshot\"]\n\n\ndef test_save_snapshot_with_output():\n    with patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\") as mock_execute:\n        result = runner.invoke(app, [\"save-snapshot\", \"--output\", \"/tmp/snap.json\"])\n        assert result.exit_code == 0\n        mock_execute.assert_called_once()\n        args, _ = mock_execute.call_args\n        assert args[0][0] == \"save-snapshot\"\n        assert \"--output\" in args[0]\n\n\ndef test_restore_snapshot_with_uv_compile():\n    with patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\") as mock_execute:\n        result = runner.invoke(app, [\"restore-snapshot\", \"/tmp/snap.json\", \"--uv-compile\"])\n        assert result.exit_code == 0\n        mock_execute.assert_called_once()\n        _, kwargs = mock_execute.call_args\n        assert kwargs.get(\"uv_compile\") is True\n\n\ndef test_restore_snapshot_with_pip_flags():\n    with patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\") as mock_execute:\n        result = runner.invoke(app, [\"restore-snapshot\", \"/tmp/snap.json\", \"--pip-non-url\", \"--pip-local-url\"])\n        assert result.exit_code == 0\n        mock_execute.assert_called_once()\n        args, _ = mock_execute.call_args\n        assert \"--pip-non-url\" in args[0]\n        assert \"--pip-local-url\" in args[0]\n\n\ndef test_restore_dependencies_with_uv_compile():\n    with patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\") as mock_execute:\n        result = runner.invoke(app, [\"restore-dependencies\", \"--uv-compile\"])\n        assert result.exit_code == 0\n        mock_execute.assert_called_once()\n        _, kwargs = mock_execute.call_args\n        assert kwargs.get(\"uv_compile\") is True\n\n\ndef test_update_with_uv_compile():\n    with (\n        patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\") as mock_execute,\n        patch(\"comfy_cli.command.custom_nodes.command.update_node_id_cache\"),\n    ):\n        result = runner.invoke(app, [\"update\", \"test-node\", \"--uv-compile\"])\n        assert result.exit_code == 0\n        mock_execute.assert_called_once()\n        _, kwargs = mock_execute.call_args\n        assert kwargs.get(\"uv_compile\") is True\n\n\ndef test_fix_with_uv_compile():\n    with patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\") as mock_execute:\n        result = runner.invoke(app, [\"fix\", \"test-node\", \"--uv-compile\"])\n        assert result.exit_code == 0\n        mock_execute.assert_called_once()\n        _, kwargs = mock_execute.call_args\n        assert kwargs.get(\"uv_compile\") is True\n\n\ndef test_uninstall_rejects_all():\n    result = runner.invoke(app, [\"uninstall\", \"all\"])\n    assert result.exit_code != 0\n    assert \"`uninstall all` is not allowed\" in result.output\n    assert \"Invalid command\" not in result.output\n\n\ndef test_reinstall_rejects_all():\n    result = runner.invoke(app, [\"reinstall\", \"all\"])\n    assert result.exit_code != 0\n    assert \"`reinstall all` is not allowed\" in result.output\n    assert \"Invalid command\" not in result.output\n\n\ndef test_validate_mode_rejects_invalid():\n    result = runner.invoke(app, [\"install\", \"test-node\", \"--mode\", \"invalid-mode\"])\n    assert result.exit_code != 0\n    assert \"Invalid mode\" in result.output\n\n\ndef test_install_deps_with_deps_file():\n    with patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\") as mock_execute:\n        result = runner.invoke(app, [\"install-deps\", \"--deps\", \"/tmp/deps.json\"])\n        assert result.exit_code == 0\n        mock_execute.assert_called_once()\n        args, _ = mock_execute.call_args\n        assert \"install-deps\" in args[0]\n\n\ndef test_install_deps_with_uv_compile():\n    with patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\") as mock_execute:\n        result = runner.invoke(app, [\"install-deps\", \"--deps\", \"/tmp/deps.json\", \"--uv-compile\"])\n        assert result.exit_code == 0\n        mock_execute.assert_called_once()\n        _, kwargs = mock_execute.call_args\n        assert kwargs.get(\"uv_compile\") is True\n\n\ndef test_install_deps_no_args_shows_error():\n    with patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\"):\n        result = runner.invoke(app, [\"install-deps\"])\n        assert \"One of --deps or --workflow\" in result.output\n\n\ndef test_restore_snapshot_with_pip_non_local_url():\n    with patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\") as mock_execute:\n        result = runner.invoke(app, [\"restore-snapshot\", \"/tmp/snap.json\", \"--pip-non-local-url\"])\n        assert result.exit_code == 0\n        mock_execute.assert_called_once()\n        args, _ = mock_execute.call_args\n        assert \"--pip-non-local-url\" in args[0]\n\n\ndef test_update_calls_update_node_id_cache():\n    with (\n        patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\") as mock_execute,\n        patch(\"comfy_cli.command.custom_nodes.command.update_node_id_cache\") as mock_cache,\n    ):\n        result = runner.invoke(app, [\"update\", \"test-node\"])\n        assert result.exit_code == 0\n        mock_execute.assert_called_once()\n        mock_cache.assert_called_once()\n\n\ndef test_uninstall_calls_execute():\n    with patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\") as mock_execute:\n        result = runner.invoke(app, [\"uninstall\", \"test-node\"])\n        assert result.exit_code == 0\n        mock_execute.assert_called_once()\n        args, _ = mock_execute.call_args\n        assert args[0] == [\"uninstall\", \"test-node\"]\n\n\ndef test_show_installed():\n    with patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\") as mock_execute:\n        result = runner.invoke(app, [\"show\", \"installed\"])\n        assert result.exit_code == 0\n        mock_execute.assert_called_once()\n        args, _ = mock_execute.call_args\n        assert args[0] == [\"show\", \"installed\"]\n\n\ndef test_install_deps_with_workflow(tmp_path):\n    workflow_file = tmp_path / \"workflow.json\"\n    workflow_file.write_text(\"{}\")\n    with (\n        patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\") as mock_execute,\n        patch(\"comfy_cli.command.custom_nodes.command.workspace_manager\") as mock_ws,\n    ):\n        mock_ws.config_manager.get_config_path.return_value = str(tmp_path)\n        result = runner.invoke(app, [\"install-deps\", \"--workflow\", str(workflow_file)])\n        assert result.exit_code == 0\n        assert mock_execute.call_count == 2\n        first_call_args = mock_execute.call_args_list[0][0][0]\n        second_call_args = mock_execute.call_args_list[1][0][0]\n        assert first_call_args[0] == \"deps-in-workflow\"\n        assert second_call_args[0] == \"install-deps\"\n\n\ndef test_install_rejects_all():\n    result = runner.invoke(app, [\"install\", \"all\"])\n    assert result.exit_code != 0\n    assert \"`install all` is not allowed\" in result.output\n    assert \"Invalid command\" not in result.output\n\n\ndef test_simple_show_installed():\n    with patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\") as mock_execute:\n        result = runner.invoke(app, [\"simple-show\", \"installed\"])\n        assert result.exit_code == 0\n        mock_execute.assert_called_once()\n        args, _ = mock_execute.call_args\n        assert args[0] == [\"simple-show\", \"installed\"]\n\n\ndef test_show_with_channel():\n    with patch(\"comfy_cli.command.custom_nodes.command.execute_cm_cli\") as mock_execute:\n        result = runner.invoke(app, [\"show\", \"installed\", \"--channel\", \"dev\"])\n        assert result.exit_code == 0\n        mock_execute.assert_called_once()\n        _, kwargs = mock_execute.call_args\n        assert kwargs.get(\"channel\") == \"dev\"\n\n\nclass TestRegistryInstallDownloadError:\n    \"\"\"registry-install must catch DownloadException, surface a friendly one-line\n    error via ui.display_error_message, and exit cleanly — never raise a traceback.\"\"\"\n\n    def _invoke(self, tmp_path, download_side_effect):\n        fake_version = MagicMock(download_url=\"http://example.com/node.zip\", version=\"1.0.0\")\n\n        with (\n            patch(\"comfy_cli.command.custom_nodes.command.registry_api\") as mock_api,\n            patch(\"comfy_cli.command.custom_nodes.command.workspace_manager\") as mock_ws,\n            patch(\"comfy_cli.command.custom_nodes.command.download_file\", side_effect=download_side_effect) as mock_dl,\n            patch(\"comfy_cli.command.custom_nodes.command.ui\") as mock_ui,\n            patch(\"comfy_cli.command.custom_nodes.command.extract_package_as_zip\") as mock_extract,\n            patch(\"comfy_cli.command.custom_nodes.command.execute_install_script\") as mock_script,\n        ):\n            mock_api.install_node.return_value = fake_version\n            mock_ws.workspace_path = str(tmp_path)\n            result = runner.invoke(app, [\"registry-install\", \"test-node\"])\n            return result, mock_ui, mock_dl, mock_extract, mock_script\n\n    def test_download_exception_caught_and_reported(self, tmp_path):\n        result, mock_ui, mock_dl, mock_extract, mock_script = self._invoke(\n            tmp_path, DownloadException(\"server unreachable\")\n        )\n\n        # Must exit non-zero so automation / CI can detect the failure.\n        assert result.exit_code == 1\n        mock_dl.assert_called_once()\n        mock_ui.display_error_message.assert_called_once()\n        (msg,), _ = mock_ui.display_error_message.call_args\n        assert \"test-node\" in msg\n        assert \"server unreachable\" in msg\n\n    def test_no_extract_or_install_script_after_failure(self, tmp_path):\n        \"\"\"After a download failure we must not try to unzip or run the install script.\"\"\"\n        result, _mock_ui, _mock_dl, mock_extract, mock_script = self._invoke(tmp_path, DownloadException(\"boom\"))\n\n        assert result.exit_code == 1\n        mock_extract.assert_not_called()\n        mock_script.assert_not_called()\n\n    def test_no_traceback_in_output(self, tmp_path):\n        result, _mock_ui, _mock_dl, _mock_extract, _mock_script = self._invoke(tmp_path, DownloadException(\"boom\"))\n\n        assert \"Traceback\" not in result.output\n        assert \"DownloadException\" not in result.output\n"
  },
  {
    "path": "tests/comfy_cli/command/nodes/test_pack.py",
    "content": "import subprocess\nimport zipfile\nfrom unittest.mock import patch\n\nfrom typer.testing import CliRunner\n\nfrom comfy_cli.cmdline import app\nfrom comfy_cli.registry.config_parser import extract_node_configuration\n\nPYPROJECT = \"\"\"\\\n[project]\nname = \"test-node\"\nversion = \"1.0.0\"\ndescription = \"A test node\"\nlicense = {text = \"MIT\"}\n\n[tool.comfy]\nPublisherId = \"test-publisher\"\nDisplayName = \"Test Node\"\nincludes = [\"models\"]\n\"\"\"\n\n\ndef test_pack_creates_zip_with_correct_contents(tmp_path, monkeypatch):\n    \"\"\"Full integration: git repo + pyproject.toml + .comfyignore + includes -> zip.\n\n    Verifies that `comfy node pack`:\n    - includes git-tracked files\n    - excludes files matched by .comfyignore (even if git-tracked)\n    - excludes untracked files\n    - force-includes directories listed in [tool.comfy] includes (even if untracked)\n    - does not include the zip file itself\n    \"\"\"\n    monkeypatch.chdir(tmp_path)\n    # extract_node_configuration's default path is frozen at import time;\n    # patch it so it reads pyproject.toml from the temp directory.\n    monkeypatch.setattr(extract_node_configuration, \"__defaults__\", (str(tmp_path / \"pyproject.toml\"),))\n\n    (tmp_path / \"pyproject.toml\").write_text(PYPROJECT)\n    (tmp_path / \"__init__.py\").write_text(\"# entry\\n\")\n    (tmp_path / \"nodes.py\").write_text(\"class MyNode: pass\\n\")\n    (tmp_path / \".comfyignore\").write_text(\"*.log\\n\")\n    (tmp_path / \"debug.log\").write_text(\"log output\\n\")\n\n    # Non-git-tracked directory listed in includes\n    (tmp_path / \"models\").mkdir()\n    (tmp_path / \"models\" / \"weights.bin\").write_bytes(b\"\\x00\" * 8)\n\n    # Init git and commit (debug.log is git-tracked but .comfyignore'd)\n    subprocess.run([\"git\", \"init\"], cwd=tmp_path, capture_output=True, check=True)\n    subprocess.run([\"git\", \"config\", \"user.email\", \"t@t\"], cwd=tmp_path, capture_output=True, check=True)\n    subprocess.run([\"git\", \"config\", \"user.name\", \"T\"], cwd=tmp_path, capture_output=True, check=True)\n    subprocess.run(\n        [\"git\", \"add\", \"pyproject.toml\", \"__init__.py\", \"nodes.py\", \".comfyignore\", \"debug.log\"],\n        cwd=tmp_path,\n        capture_output=True,\n        check=True,\n    )\n    subprocess.run([\"git\", \"commit\", \"-m\", \"init\"], cwd=tmp_path, capture_output=True, check=True)\n\n    # Create untracked file after commit\n    (tmp_path / \"untracked.txt\").write_text(\"not in git\\n\")\n\n    with patch(\"comfy_cli.tracking.prompt_tracking_consent\"):\n        result = CliRunner().invoke(app, [\"node\", \"pack\"])\n    assert result.exit_code == 0, result.output\n\n    zip_path = tmp_path / \"node.zip\"\n    assert zip_path.exists()\n\n    with zipfile.ZipFile(zip_path) as zf:\n        names = set(zf.namelist())\n\n    # Git-tracked files present\n    assert \"pyproject.toml\" in names\n    assert \"__init__.py\" in names\n    assert \"nodes.py\" in names\n\n    # .comfyignore excludes git-tracked file\n    assert \"debug.log\" not in names\n\n    # Untracked file excluded\n    assert \"untracked.txt\" not in names\n\n    # includes directory added despite not being git-tracked\n    assert \"models/weights.bin\" in names\n\n    # Zip itself not included\n    assert \"node.zip\" not in names\n"
  },
  {
    "path": "tests/comfy_cli/command/nodes/test_publish.py",
    "content": "from unittest.mock import MagicMock, patch\n\nfrom typer.testing import CliRunner\n\nfrom comfy_cli.command.custom_nodes.command import app\nfrom comfy_cli.registry.types import ComfyConfig, ProjectConfig, PyProjectConfig\n\nrunner = CliRunner()\n\n\ndef create_mock_config(includes_list=None):\n    if includes_list is None:\n        includes_list = []\n\n    mock_pyproject_config = MagicMock()\n\n    mock_tool_comfy_section = MagicMock()\n    mock_tool_comfy_section.name = \"test-node\"\n    mock_tool_comfy_section.version = \"0.1.0\"\n    mock_tool_comfy_section.description = \"A test node.\"\n    mock_tool_comfy_section.author = \"Test Author\"\n    mock_tool_comfy_section.license = \"MIT\"\n    mock_tool_comfy_section.tags = [\"test\"]\n    mock_tool_comfy_section.repository = \"http://example.com/repo\"\n    mock_tool_comfy_section.homepage = \"http://example.com/home\"\n    mock_tool_comfy_section.documentation = \"http://example.com/docs\"\n    mock_tool_comfy_section.includes = includes_list\n\n    mock_pyproject_config.tool_comfy = mock_tool_comfy_section\n\n    return mock_pyproject_config\n\n\ndef test_publish_fails_on_security_violations():\n    # Mock subprocess.run to simulate security violations\n    mock_result = MagicMock()\n    mock_result.returncode = 1\n    mock_result.stdout = \"S102 Use of exec() detected\"\n\n    with (\n        patch(\"subprocess.run\", return_value=mock_result),\n        patch(\"typer.prompt\", return_value=\"test-token\"),\n    ):\n        result = runner.invoke(app, [\"publish\"])\n\n        # TODO: re-enable exit when we disable exec and eval\n        # assert result.exit_code == 1\n        # assert \"Security issues found\" in result.stdout\n        assert \"Security warnings found\" in result.stdout\n\n\ndef test_publish_continues_on_no_security_violations():\n    # Mock subprocess.run to simulate no violations\n    mock_result = MagicMock()\n    mock_result.returncode = 0\n    mock_result.stdout = \"\"\n\n    with (\n        patch(\"subprocess.run\", return_value=mock_result),\n        patch(\"comfy_cli.command.custom_nodes.command.extract_node_configuration\") as mock_extract,\n        patch(\"typer.prompt\") as mock_prompt,\n        patch(\"comfy_cli.command.custom_nodes.command.registry_api.publish_node_version\") as mock_publish,\n        patch(\"comfy_cli.command.custom_nodes.command.zip_files\") as mock_zip,\n        patch(\"comfy_cli.command.custom_nodes.command.upload_file_to_signed_url\") as mock_upload,\n    ):\n        # Setup the mocks\n        mock_extract.return_value = create_mock_config()\n\n        mock_prompt.return_value = \"test-token\"\n        mock_publish.return_value = MagicMock(signedUrl=\"https://test.url\")\n\n        # Run the publish command\n        _result = runner.invoke(app, [\"publish\"])\n\n        # Verify the publish flow continued\n        assert mock_extract.called\n        assert mock_publish.called\n        assert mock_zip.called\n        assert mock_upload.called\n\n\ndef test_publish_handles_missing_ruff():\n    with patch(\"subprocess.run\", side_effect=FileNotFoundError()):\n        result = runner.invoke(app, [\"publish\"])\n\n        assert result.exit_code == 1\n        assert \"Ruff is not installed\" in result.stdout\n\n\ndef test_publish_with_token_option():\n    # Mock subprocess.run to simulate no violations\n    mock_result = MagicMock()\n    mock_result.returncode = 0\n    mock_result.stdout = \"\"\n\n    with (\n        patch(\"subprocess.run\", return_value=mock_result),\n        patch(\"comfy_cli.command.custom_nodes.command.extract_node_configuration\") as mock_extract,\n        patch(\"comfy_cli.command.custom_nodes.command.registry_api.publish_node_version\") as mock_publish,\n        patch(\"comfy_cli.command.custom_nodes.command.zip_files\") as mock_zip,\n        patch(\"comfy_cli.command.custom_nodes.command.upload_file_to_signed_url\") as mock_upload,\n    ):\n        # Setup the mocks\n        mock_extract.return_value = create_mock_config()\n\n        mock_publish.return_value = MagicMock(signedUrl=\"https://test.url\")\n\n        # Run the publish command with token\n        _result = runner.invoke(app, [\"publish\", \"--token\", \"test-token\"])\n\n        # Verify the publish flow worked with provided token\n        assert mock_extract.called\n        assert mock_publish.called\n        assert mock_zip.called\n        assert mock_upload.called\n\n\ndef test_publish_exits_on_upload_failure():\n    # Mock subprocess.run to simulate no violations\n    mock_result = MagicMock()\n    mock_result.returncode = 0\n    mock_result.stdout = \"\"\n\n    with (\n        patch(\"subprocess.run\", return_value=mock_result),\n        patch(\"comfy_cli.command.custom_nodes.command.extract_node_configuration\") as mock_extract,\n        patch(\"typer.prompt\", return_value=\"test-token\"),\n        patch(\"comfy_cli.command.custom_nodes.command.registry_api.publish_node_version\") as mock_publish,\n        patch(\"comfy_cli.command.custom_nodes.command.zip_files\") as mock_zip,\n        patch(\"comfy_cli.command.custom_nodes.command.upload_file_to_signed_url\") as mock_upload,\n    ):\n        # Setup the mocks\n        mock_extract.return_value = create_mock_config()\n\n        mock_publish.return_value = MagicMock(signedUrl=\"https://test.url\")\n        mock_upload.side_effect = Exception(\"Upload failed with status code: 403\")\n\n        # Run the publish command\n        result = runner.invoke(app, [\"publish\"])\n\n        # Verify the command exited with error\n        assert result.exit_code == 1\n        assert mock_extract.called\n        assert mock_publish.called\n        assert mock_zip.called\n        assert mock_upload.called\n\n\ndef test_publish_fails_when_config_is_none():\n    # extract_node_configuration returns None when pyproject.toml is missing;\n    # validate_node_for_publishing must exit 1 (not crash on the subsequent\n    # `config.project.version` access).\n    with patch(\n        \"comfy_cli.command.custom_nodes.command.extract_node_configuration\",\n        return_value=None,\n    ):\n        result = runner.invoke(app, [\"validate\"])\n        assert result.exit_code == 1\n\n\ndef test_publish_fails_when_version_is_empty():\n    # Guards against issue #294: dynamic versions that failed to resolve must\n    # not silently POST an empty `version` to the registry. validate_node_for_publishing\n    # should exit 1 with a user-facing error pointing at [tool.comfy.version].path.\n    empty_version_config = PyProjectConfig(\n        project=ProjectConfig(name=\"x\", version=\"\"),\n        tool_comfy=ComfyConfig(publisher_id=\"pub\"),\n    )\n    with patch(\n        \"comfy_cli.command.custom_nodes.command.extract_node_configuration\",\n        return_value=empty_version_config,\n    ):\n        result = runner.invoke(app, [\"validate\"])\n        assert result.exit_code == 1\n        assert \"project version is empty\" in result.stdout\n        assert \"[tool.comfy.version].path\" in result.stdout\n\n\ndef test_publish_with_includes_parameter():\n    # Mock subprocess.run to simulate no violations\n    mock_result = MagicMock()\n    mock_result.returncode = 0\n    mock_result.stdout = \"\"\n\n    with (\n        patch(\"subprocess.run\", return_value=mock_result),\n        patch(\"comfy_cli.command.custom_nodes.command.extract_node_configuration\") as mock_extract,\n        patch(\"comfy_cli.command.custom_nodes.command.registry_api.publish_node_version\") as mock_publish,\n        patch(\"comfy_cli.command.custom_nodes.command.zip_files\") as mock_zip,\n        patch(\"comfy_cli.command.custom_nodes.command.upload_file_to_signed_url\") as mock_upload,\n    ):\n        includes = [\"/js\", \"/dist\"]\n\n        # Setup the mocks\n        mock_extract.return_value = create_mock_config(includes)\n\n        mock_publish.return_value = MagicMock(signedUrl=\"https://test.url\")\n\n        # Run the publish command with token\n        _result = runner.invoke(app, [\"publish\", \"--token\", \"test-token\"])\n\n        # Verify the publish flow worked with provided token\n        assert mock_extract.called\n        assert mock_publish.called\n        assert mock_zip.called\n        assert mock_upload.called\n"
  },
  {
    "path": "tests/comfy_cli/command/test_bisect_parse.py",
    "content": "from comfy_cli.command.custom_nodes.bisect_custom_nodes import parse_cm_output\n\nCM_OUTPUT_REAL = \"\"\"\\\nFETCH ComfyRegistry Data: 5/85\nFETCH ComfyRegistry Data: 10/85\nFETCH ComfyRegistry Data: 15/85\nFETCH ComfyRegistry Data: 20/85\nFETCH ComfyRegistry Data: 25/85\nFETCH ComfyRegistry Data: 30/85\nFETCH ComfyRegistry Data: 35/85\nFETCH ComfyRegistry Data: 40/85\nFETCH ComfyRegistry Data: 45/85\nFETCH ComfyRegistry Data: 50/85\nFETCH ComfyRegistry Data: 55/85\nFETCH ComfyRegistry Data: 60/85\nFETCH ComfyRegistry Data: 65/85\nFETCH ComfyRegistry Data: 70/85\nFETCH ComfyRegistry Data: 75/85\nFETCH ComfyRegistry Data: 80/85\nFETCH ComfyRegistry Data: 85/85\nFETCH ComfyRegistry Data [DONE]\nFETCH DATA from: https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/custom-node-list.json [DONE]\nFETCH DATA from: https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/custom-node-list.json [DONE]\nA3D ComfyUI Integration@1.0.2\nComfyUI_ACE-Step@1.1.2\nBjornulf_custom_nodes@1.1.1\ncg-use-everywhere@6.1.0\nComfyUI-0246@1.1.3\nComfyUI ArtVenture@nightly\ncomfyui-auto-nodes-layout@0.0.1\nComfyUI-ConDelta@nightly\nComfyUI-Custom-Scripts@nightly\nComfyUI-DiaTTS@0.3.0\nComfyUI-Easy-Use@1.3.0\nComfyUI F5-TTS@nightly\nComfyUI-Florence2@1.0.3\nComfyUI@nightly\nComfyUI-GGUF@nightly\nComfyUI-Image-Filters@nightly\nComfyUI-KJNodes@nightly\ncomfyui-lmstudio-image-to-text-node@1.1.14\nComfyUI-LogicUtils@1.7.2\nComfyUI-LTXVideo@nightly\nComfyUI-Manager@nightly\nComfyUI-MMAudio@1.0.2\nComfyUI-mxToolkit@0.9.92\nComfyUI-VideoHelperSuite@1.6.1\nComfyUI Web Viewer@1.0.32\ncomfyui_controlnet_aux@1.0.7\nComfyUI_IPAdapter_plus@2.0.0\nPrompt Stash@1.2.0\nefficiency-nodes-comfyui@1.0.6\ngguf@2.1.0\ncomfyui_HiDream-Sampler@1.0.0\nLF Nodes@0.7.0\nlora-info@1.0.2\nMasquerade Nodes@nightly\nrgthree-comfy@nightly\nComfyUI-TeaCache@1.5.1\nWAS Node Suite@1.0.2\nComfyUI-ultimate-openpose-editor@nightly\nComfyUI-Dia@unknown\nComfyUI-Orpheus@unknown\n\"\"\"\n\nEXPECTED_NODES = [\n    \"A3D ComfyUI Integration@1.0.2\",\n    \"ComfyUI_ACE-Step@1.1.2\",\n    \"Bjornulf_custom_nodes@1.1.1\",\n    \"cg-use-everywhere@6.1.0\",\n    \"ComfyUI-0246@1.1.3\",\n    \"ComfyUI ArtVenture@nightly\",\n    \"comfyui-auto-nodes-layout@0.0.1\",\n    \"ComfyUI-ConDelta@nightly\",\n    \"ComfyUI-Custom-Scripts@nightly\",\n    \"ComfyUI-DiaTTS@0.3.0\",\n    \"ComfyUI-Easy-Use@1.3.0\",\n    \"ComfyUI F5-TTS@nightly\",\n    \"ComfyUI-Florence2@1.0.3\",\n    \"ComfyUI@nightly\",\n    \"ComfyUI-GGUF@nightly\",\n    \"ComfyUI-Image-Filters@nightly\",\n    \"ComfyUI-KJNodes@nightly\",\n    \"comfyui-lmstudio-image-to-text-node@1.1.14\",\n    \"ComfyUI-LogicUtils@1.7.2\",\n    \"ComfyUI-LTXVideo@nightly\",\n    \"ComfyUI-Manager@nightly\",\n    \"ComfyUI-MMAudio@1.0.2\",\n    \"ComfyUI-mxToolkit@0.9.92\",\n    \"ComfyUI-VideoHelperSuite@1.6.1\",\n    \"ComfyUI Web Viewer@1.0.32\",\n    \"comfyui_controlnet_aux@1.0.7\",\n    \"ComfyUI_IPAdapter_plus@2.0.0\",\n    \"Prompt Stash@1.2.0\",\n    \"efficiency-nodes-comfyui@1.0.6\",\n    \"gguf@2.1.0\",\n    \"comfyui_HiDream-Sampler@1.0.0\",\n    \"LF Nodes@0.7.0\",\n    \"lora-info@1.0.2\",\n    \"Masquerade Nodes@nightly\",\n    \"rgthree-comfy@nightly\",\n    \"ComfyUI-TeaCache@1.5.1\",\n    \"WAS Node Suite@1.0.2\",\n    \"ComfyUI-ultimate-openpose-editor@nightly\",\n    \"ComfyUI-Dia@unknown\",\n    \"ComfyUI-Orpheus@unknown\",\n]\n\n\nclass TestParseCmOutput:\n    def test_real_output_filters_fetch_lines(self):\n        result = parse_cm_output(CM_OUTPUT_REAL)\n        assert result == EXPECTED_NODES\n        assert len(result) == 40\n\n    def test_no_fetch_lines_in_result(self):\n        result = parse_cm_output(CM_OUTPUT_REAL)\n        for node in result:\n            assert not node.startswith(\"FETCH\"), f\"FETCH line leaked: {node}\"\n\n    def test_pinned_nodes_excluded(self):\n        pinned = {\"ComfyUI-Manager@nightly\", \"ComfyUI@nightly\"}\n        result = parse_cm_output(CM_OUTPUT_REAL, pinned)\n        assert \"ComfyUI-Manager@nightly\" not in result\n        assert \"ComfyUI@nightly\" not in result\n        assert len(result) == 38\n\n    def test_empty_output(self):\n        assert parse_cm_output(\"\") == []\n        assert parse_cm_output(\"   \\n  \\n  \") == []\n\n    def test_only_fetch_lines(self):\n        output = \"FETCH ComfyRegistry Data: 5/85\\nFETCH DATA from: foo [DONE]\\n\"\n        assert parse_cm_output(output) == []\n\n    def test_no_fetch_lines(self):\n        output = \"NodeA@1.0\\nNodeB@nightly\\n\"\n        assert parse_cm_output(output) == [\"NodeA@1.0\", \"NodeB@nightly\"]\n\n    def test_arbitrary_status_lines_filtered(self):\n        output = \"some random status line\\nINFO: loading\\nNodeA@1.0\\nDone.\\n\"\n        assert parse_cm_output(output) == [\"NodeA@1.0\"]\n"
  },
  {
    "path": "tests/comfy_cli/command/test_cm_cli_util.py",
    "content": "import subprocess\nimport sys\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\nimport typer\n\nfrom comfy_cli.command.custom_nodes import cm_cli_util\n\n\ndef _make_mock_proc(returncode, stdout_lines=None, stderr_lines=None):\n    \"\"\"Create a mock Popen process with given returncode and stdout/stderr lines.\"\"\"\n    mock_proc = MagicMock()\n    mock_proc.stdout = iter(stdout_lines or [])\n    mock_proc.stderr = iter(stderr_lines or [])\n    mock_proc.wait.return_value = returncode\n    return mock_proc\n\n\n@pytest.fixture(autouse=True)\ndef _clear_find_cm_cli_cache():\n    cm_cli_util.find_cm_cli.cache_clear()\n    yield\n    cm_cli_util.find_cm_cli.cache_clear()\n\n\n@pytest.fixture()\ndef _cm_cli_env(tmp_path):\n    mock_proc = MagicMock()\n    mock_proc.stdout = iter([\"ok\\n\"])\n    mock_proc.stderr = iter([])\n    mock_proc.wait.return_value = 0\n    with (\n        patch.object(cm_cli_util.workspace_manager, \"workspace_path\", str(tmp_path)),\n        patch.object(cm_cli_util.workspace_manager, \"set_recent_workspace\"),\n        patch(\"comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager\") as mock_cfg,\n        patch(\"comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo\", return_value=(True, None)),\n        patch(\"comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python\", return_value=\"/resolved/python\"),\n        patch(\"comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli\", return_value=True),\n        patch(\"comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen\", return_value=mock_proc) as mock_popen,\n        patch(\"comfy_cli.command.custom_nodes.cm_cli_util.DependencyCompiler\") as mock_compiler,\n    ):\n        mock_cfg.return_value.get_config_path.return_value = str(tmp_path / \"config\")\n        mock_compiler.return_value = MagicMock()\n        yield {\"mock_popen\": mock_popen, \"mock_proc\": mock_proc, \"mock_compiler\": mock_compiler}\n\n\nclass TestFindCmCli:\n    def test_returns_true_when_module_exists_same_python(self):\n        \"\"\"When workspace Python == sys.executable, checks importlib.util.find_spec.\"\"\"\n        with (\n            patch.object(cm_cli_util.workspace_manager, \"workspace_path\", \"/fake/workspace\"),\n            patch(\n                \"comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python\",\n                return_value=sys.executable,\n            ),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.importlib.util.find_spec\", return_value=MagicMock()),\n        ):\n            assert cm_cli_util.find_cm_cli() is True\n\n    def test_returns_true_no_workspace_module_exists(self):\n        \"\"\"When no workspace, falls back to importlib.util.find_spec.\"\"\"\n        with (\n            patch.object(cm_cli_util.workspace_manager, \"workspace_path\", None),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.importlib.util.find_spec\", return_value=MagicMock()),\n        ):\n            assert cm_cli_util.find_cm_cli() is True\n\n    def test_returns_false_when_module_missing(self):\n        with (\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.importlib.util.find_spec\", return_value=None),\n            patch.object(cm_cli_util.workspace_manager, \"workspace_path\", None),\n        ):\n            assert cm_cli_util.find_cm_cli() is False\n\n    def test_returns_true_when_found_in_workspace_venv(self):\n        mock_result = MagicMock(returncode=0)\n        with (\n            patch.object(cm_cli_util.workspace_manager, \"workspace_path\", \"/fake/workspace\"),\n            patch(\n                \"comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python\",\n                return_value=\"/fake/venv/python\",\n            ),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.subprocess.run\", return_value=mock_result),\n        ):\n            assert cm_cli_util.find_cm_cli() is True\n\n    def test_returns_false_when_missing_from_workspace_venv(self):\n        mock_result = MagicMock(returncode=1)\n        with (\n            patch.object(cm_cli_util.workspace_manager, \"workspace_path\", \"/fake/workspace\"),\n            patch(\n                \"comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python\",\n                return_value=\"/fake/venv/python\",\n            ),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.subprocess.run\", return_value=mock_result),\n        ):\n            assert cm_cli_util.find_cm_cli() is False\n\n    def test_workspace_python_checked_first_not_cli_interpreter(self):\n        \"\"\"Core bug fix: when workspace Python differs, check workspace NOT cli env.\n\n        Even if importlib.util.find_spec would return True in the CLI env,\n        the function must check the workspace Python since that's what\n        execute_cm_cli() actually uses.\n        \"\"\"\n        mock_result = MagicMock(returncode=1)  # cm_cli NOT in workspace venv\n        with (\n            patch.object(cm_cli_util.workspace_manager, \"workspace_path\", \"/fake/workspace\"),\n            patch(\n                \"comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python\",\n                return_value=\"/fake/venv/python\",\n            ),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.subprocess.run\", return_value=mock_result),\n            # find_spec would return True, but should NOT be called when workspace Python differs\n            patch(\n                \"comfy_cli.command.custom_nodes.cm_cli_util.importlib.util.find_spec\",\n                return_value=MagicMock(),\n            ) as mock_spec,\n        ):\n            assert cm_cli_util.find_cm_cli() is False\n            mock_spec.assert_not_called()\n\n    def test_result_is_cached(self):\n        with (\n            patch.object(cm_cli_util.workspace_manager, \"workspace_path\", None),\n            patch(\n                \"comfy_cli.command.custom_nodes.cm_cli_util.importlib.util.find_spec\", return_value=MagicMock()\n            ) as mock_spec,\n        ):\n            cm_cli_util.find_cm_cli()\n            cm_cli_util.find_cm_cli()\n            mock_spec.assert_called_once()\n\n    def test_cache_clear_allows_recheck(self):\n        with (\n            patch(\n                \"comfy_cli.command.custom_nodes.cm_cli_util.importlib.util.find_spec\", return_value=None\n            ) as mock_spec,\n            patch.object(cm_cli_util.workspace_manager, \"workspace_path\", None),\n        ):\n            assert cm_cli_util.find_cm_cli() is False\n            cm_cli_util.find_cm_cli.cache_clear()\n            mock_spec.return_value = MagicMock()\n            assert cm_cli_util.find_cm_cli() is True\n\n    def test_returns_false_on_subprocess_timeout(self):\n        \"\"\"When workspace Python check times out, returns False (not fallback to cli).\"\"\"\n        with (\n            patch.object(cm_cli_util.workspace_manager, \"workspace_path\", \"/fake/workspace\"),\n            patch(\n                \"comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python\",\n                return_value=\"/fake/venv/python\",\n            ),\n            patch(\n                \"comfy_cli.command.custom_nodes.cm_cli_util.subprocess.run\",\n                side_effect=subprocess.TimeoutExpired(cmd=\"test\", timeout=10),\n            ),\n        ):\n            assert cm_cli_util.find_cm_cli() is False\n\n\nclass TestResolveManagerGuiMode:\n    def test_returns_config_mode_when_set(self):\n        with patch(\"comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager\") as mock_cfg:\n            mock_cfg.return_value.get.side_effect = lambda k: \"disable\" if k == \"manager_gui_mode\" else None\n            assert cm_cli_util.resolve_manager_gui_mode() == \"disable\"\n\n    def test_legacy_false_returns_disable(self):\n        with patch(\"comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager\") as mock_cfg:\n            mock_cfg.return_value.get.side_effect = lambda k: \"False\" if k == \"manager_gui_enabled\" else None\n            assert cm_cli_util.resolve_manager_gui_mode() == \"disable\"\n\n    def test_legacy_true_returns_enable_gui(self):\n        with patch(\"comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager\") as mock_cfg:\n            mock_cfg.return_value.get.side_effect = lambda k: \"True\" if k == \"manager_gui_enabled\" else None\n            assert cm_cli_util.resolve_manager_gui_mode() == \"enable-gui\"\n\n    def test_legacy_boolean_0_returns_disable(self):\n        with patch(\"comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager\") as mock_cfg:\n            mock_cfg.return_value.get.side_effect = lambda k: \"0\" if k == \"manager_gui_enabled\" else None\n            assert cm_cli_util.resolve_manager_gui_mode() == \"disable\"\n\n    def test_no_config_manager_available_returns_enable_gui(self):\n        with (\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager\") as mock_cfg,\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli\", return_value=True),\n        ):\n            mock_cfg.return_value.get.return_value = None\n            assert cm_cli_util.resolve_manager_gui_mode() == \"enable-gui\"\n\n    def test_no_config_no_manager_returns_not_installed_value(self):\n        with (\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager\") as mock_cfg,\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli\", return_value=False),\n        ):\n            mock_cfg.return_value.get.return_value = None\n            assert cm_cli_util.resolve_manager_gui_mode(\"not-installed\") == \"not-installed\"\n\n    def test_no_config_no_manager_returns_none_by_default(self):\n        with (\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager\") as mock_cfg,\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli\", return_value=False),\n        ):\n            mock_cfg.return_value.get.return_value = None\n            assert cm_cli_util.resolve_manager_gui_mode() is None\n\n\nclass TestExecuteCmCli:\n    def test_no_workspace_raises_exit(self):\n        with (\n            patch.object(cm_cli_util.workspace_manager, \"workspace_path\", None),\n            pytest.raises(typer.Exit),\n        ):\n            cm_cli_util.execute_cm_cli([\"show\"])\n\n    def test_no_cm_cli_raises_exit(self):\n        with (\n            patch.object(cm_cli_util.workspace_manager, \"workspace_path\", \"/workspace\"),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli\", return_value=False),\n            pytest.raises(typer.Exit),\n        ):\n            cm_cli_util.execute_cm_cli([\"show\"])\n\n    def test_happy_path_returns_stdout(self, _cm_cli_env):\n        result = cm_cli_util.execute_cm_cli([\"show\", \"installed\"])\n        assert result == \"ok\\n\"\n\n    def test_cmd_uses_python_m_cm_cli(self, _cm_cli_env):\n        cm_cli_util.execute_cm_cli([\"show\"])\n        cmd = _cm_cli_env[\"mock_popen\"].call_args[0][0]\n        assert cmd[:3] == [\"/resolved/python\", \"-m\", \"cm_cli\"]\n\n    def test_channel_appended(self, _cm_cli_env):\n        cm_cli_util.execute_cm_cli([\"show\"], channel=\"stable\")\n        cmd = _cm_cli_env[\"mock_popen\"].call_args[0][0]\n        assert \"--channel\" in cmd\n        assert cmd[cmd.index(\"--channel\") + 1] == \"stable\"\n\n    def test_uv_compile_flag(self, _cm_cli_env):\n        cm_cli_util.execute_cm_cli([\"install\", \"node\"], uv_compile=True)\n        cmd = _cm_cli_env[\"mock_popen\"].call_args[0][0]\n        assert \"--uv-compile\" in cmd\n\n    def test_fast_deps_adds_no_deps(self, _cm_cli_env):\n        cm_cli_util.execute_cm_cli([\"install\", \"node\"], fast_deps=True)\n        cmd = _cm_cli_env[\"mock_popen\"].call_args[0][0]\n        assert \"--no-deps\" in cmd\n\n    def test_no_deps_adds_no_deps(self, _cm_cli_env):\n        cm_cli_util.execute_cm_cli([\"install\", \"node\"], no_deps=True)\n        cmd = _cm_cli_env[\"mock_popen\"].call_args[0][0]\n        assert \"--no-deps\" in cmd\n\n    def test_uv_compile_takes_precedence_over_fast_deps(self, _cm_cli_env):\n        cm_cli_util.execute_cm_cli([\"install\", \"node\"], uv_compile=True, fast_deps=True)\n        cmd = _cm_cli_env[\"mock_popen\"].call_args[0][0]\n        assert \"--uv-compile\" in cmd\n        assert \"--no-deps\" not in cmd\n\n    def test_mode_appended(self, _cm_cli_env):\n        cm_cli_util.execute_cm_cli([\"install\", \"node\"], mode=\"remote\")\n        cmd = _cm_cli_env[\"mock_popen\"].call_args[0][0]\n        assert \"--mode\" in cmd\n        assert cmd[cmd.index(\"--mode\") + 1] == \"remote\"\n\n    def test_error_returncode_1_returns_none(self, tmp_path):\n        mock_proc = _make_mock_proc(1)\n        with (\n            patch.object(cm_cli_util.workspace_manager, \"workspace_path\", str(tmp_path)),\n            patch.object(cm_cli_util.workspace_manager, \"set_recent_workspace\"),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager\") as mock_cfg,\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo\", return_value=(True, None)),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python\", return_value=\"/python\"),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli\", return_value=True),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen\", return_value=mock_proc),\n        ):\n            mock_cfg.return_value.get_config_path.return_value = str(tmp_path)\n            result = cm_cli_util.execute_cm_cli([\"install\", \"node\"])\n            assert result is None\n\n    def test_error_returncode_2_returns_none(self, tmp_path):\n        mock_proc = _make_mock_proc(2)\n        with (\n            patch.object(cm_cli_util.workspace_manager, \"workspace_path\", str(tmp_path)),\n            patch.object(cm_cli_util.workspace_manager, \"set_recent_workspace\"),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager\") as mock_cfg,\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo\", return_value=(True, None)),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python\", return_value=\"/python\"),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli\", return_value=True),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen\", return_value=mock_proc),\n        ):\n            mock_cfg.return_value.get_config_path.return_value = str(tmp_path)\n            result = cm_cli_util.execute_cm_cli([\"install\", \"node\"])\n            assert result is None\n\n    def test_error_other_returncode_raises(self, tmp_path):\n        mock_proc = _make_mock_proc(42)\n        with (\n            patch.object(cm_cli_util.workspace_manager, \"workspace_path\", str(tmp_path)),\n            patch.object(cm_cli_util.workspace_manager, \"set_recent_workspace\"),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager\") as mock_cfg,\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo\", return_value=(True, None)),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python\", return_value=\"/python\"),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli\", return_value=True),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen\", return_value=mock_proc),\n            pytest.raises(subprocess.CalledProcessError, match=\"42\"),\n        ):\n            mock_cfg.return_value.get_config_path.return_value = str(tmp_path)\n            cm_cli_util.execute_cm_cli([\"install\", \"node\"])\n\n    def test_raise_on_error_reraises(self, tmp_path):\n        mock_proc = _make_mock_proc(1)\n        with (\n            patch.object(cm_cli_util.workspace_manager, \"workspace_path\", str(tmp_path)),\n            patch.object(cm_cli_util.workspace_manager, \"set_recent_workspace\"),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager\") as mock_cfg,\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo\", return_value=(True, None)),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python\", return_value=\"/python\"),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli\", return_value=True),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen\", return_value=mock_proc),\n            pytest.raises(subprocess.CalledProcessError),\n        ):\n            mock_cfg.return_value.get_config_path.return_value = str(tmp_path)\n            cm_cli_util.execute_cm_cli([\"install\", \"node\"], raise_on_error=True)\n\n    def test_fast_deps_triggers_dependency_compiler(self, tmp_path):\n        mock_proc = MagicMock()\n        mock_proc.stdout = iter([\"ok\\n\"])\n        mock_proc.stderr = iter([])\n        mock_proc.wait.return_value = 0\n        with (\n            patch.object(cm_cli_util.workspace_manager, \"workspace_path\", str(tmp_path)),\n            patch.object(cm_cli_util.workspace_manager, \"set_recent_workspace\"),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager\") as mock_cfg,\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo\", return_value=(True, None)),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python\", return_value=\"/python\"),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli\", return_value=True),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen\", return_value=mock_proc),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.DependencyCompiler\") as mock_compiler,\n        ):\n            mock_cfg.return_value.get_config_path.return_value = str(tmp_path)\n            mock_instance = MagicMock()\n            mock_compiler.return_value = mock_instance\n            cm_cli_util.execute_cm_cli([\"install\", \"node\"], fast_deps=True)\n            mock_compiler.assert_called_once()\n            mock_instance.compile_deps.assert_called_once()\n            mock_instance.install_deps.assert_called_once()\n\n    def test_fast_deps_non_dependency_cmd_skips_compiler(self, tmp_path):\n        mock_proc = MagicMock()\n        mock_proc.stdout = iter([\"ok\\n\"])\n        mock_proc.stderr = iter([])\n        mock_proc.wait.return_value = 0\n        with (\n            patch.object(cm_cli_util.workspace_manager, \"workspace_path\", str(tmp_path)),\n            patch.object(cm_cli_util.workspace_manager, \"set_recent_workspace\"),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager\") as mock_cfg,\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo\", return_value=(True, None)),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python\", return_value=\"/python\"),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli\", return_value=True),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen\", return_value=mock_proc),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.DependencyCompiler\") as mock_compiler,\n        ):\n            mock_cfg.return_value.get_config_path.return_value = str(tmp_path)\n            cm_cli_util.execute_cm_cli([\"show\", \"all\"], fast_deps=True)\n            mock_compiler.assert_not_called()\n\n    def test_sets_comfyui_path_env(self, _cm_cli_env):\n        cm_cli_util.execute_cm_cli([\"show\"])\n        env = _cm_cli_env[\"mock_popen\"].call_args[1][\"env\"]\n        assert \"COMFYUI_PATH\" in env\n\n    def test_captures_stderr_via_pipe(self, _cm_cli_env):\n        \"\"\"Verify stderr is captured via PIPE (not inherited) to avoid swallowing errors.\"\"\"\n        cm_cli_util.execute_cm_cli([\"show\"])\n        kwargs = _cm_cli_env[\"mock_popen\"].call_args[1]\n        assert kwargs[\"stderr\"] == subprocess.PIPE\n"
  },
  {
    "path": "tests/comfy_cli/command/test_code_search.py",
    "content": "\"\"\"Tests for the code-search command.\"\"\"\n\nimport json\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\nimport requests\nfrom typer.testing import CliRunner\n\nfrom comfy_cli.command.code_search import (\n    API_URL,\n    DEFAULT_COUNT,\n    REQUEST_TIMEOUT,\n    _build_query,\n    _fetch_results,\n    _format_results,\n    _get_stats,\n    _print_results,\n    app,\n)\n\nrunner = CliRunner()\n\n\n# ---------------------------------------------------------------------------\n# Fixtures\n# ---------------------------------------------------------------------------\n\n_REPO_INFO = {\n    \"name\": \"github.com/Comfy-Org/ComfyUI\",\n    \"defaultBranch\": {\n        \"name\": \"refs/heads/main\",\n        \"displayName\": \"main\",\n        \"target\": {\"commit\": {\"oid\": \"abc123def456\", \"abbreviatedOID\": \"abc123d\"}},\n    },\n}\n\n\n@pytest.fixture\ndef search_response():\n    \"\"\"A realistic Sourcegraph search node.\"\"\"\n    return {\n        \"stats\": {\n            \"approximateResultCount\": \"42\",\n            \"languages\": [{\"name\": \"Python\", \"totalBytes\": 1234, \"totalLines\": 100}],\n        },\n        \"results\": {\n            \"matchCount\": 3,\n            \"limitHit\": False,\n            \"approximateResultCount\": \"42\",\n            \"elapsedMilliseconds\": 50,\n            \"results\": [\n                {\n                    \"__typename\": \"FileMatch\",\n                    \"repository\": _REPO_INFO,\n                    \"file\": {\"path\": \"nodes.py\"},\n                    \"lineMatches\": [\n                        {\"preview\": \"class LoadImage:\", \"lineNumber\": 41, \"offsetAndLengths\": [[6, 9]]},\n                        {\n                            \"preview\": \"    def load_image(self, image):\",\n                            \"lineNumber\": 55,\n                            \"offsetAndLengths\": [[8, 10]],\n                        },\n                    ],\n                },\n                {\n                    \"__typename\": \"FileMatch\",\n                    \"repository\": _REPO_INFO,\n                    \"file\": {\"path\": \"server.py\"},\n                    \"lineMatches\": [\n                        {\"preview\": \"from nodes import LoadImage\", \"lineNumber\": 9, \"offsetAndLengths\": [[20, 9]]},\n                    ],\n                },\n            ],\n        },\n    }\n\n\n@pytest.fixture\ndef raw_api_response(search_response):\n    \"\"\"Full API response wrapping the search node.\"\"\"\n    return {\"data\": {\"search\": search_response}}\n\n\n@pytest.fixture\ndef empty_search():\n    \"\"\"A search node with no results.\"\"\"\n    return {\n        \"stats\": {\"approximateResultCount\": \"0\", \"languages\": []},\n        \"results\": {\n            \"matchCount\": 0,\n            \"limitHit\": False,\n            \"approximateResultCount\": \"0\",\n            \"elapsedMilliseconds\": 10,\n            \"results\": [],\n        },\n    }\n\n\n@pytest.fixture\ndef empty_api_response(empty_search):\n    return {\"data\": {\"search\": empty_search}}\n\n\n@pytest.fixture\ndef limit_hit_search(search_response):\n    search_response[\"results\"][\"limitHit\"] = True\n    return search_response\n\n\n@pytest.fixture\ndef limit_hit_response(limit_hit_search):\n    return {\"data\": {\"search\": limit_hit_search}}\n\n\n# ---------------------------------------------------------------------------\n# _build_query tests\n# ---------------------------------------------------------------------------\n\n\nclass TestBuildQuery:\n    def test_simple_query(self):\n        assert _build_query(\"LoadImage\", None, DEFAULT_COUNT) == f\"type:file count:{DEFAULT_COUNT} LoadImage\"\n\n    def test_with_repo_short_name(self):\n        result = _build_query(\"LoadImage\", \"ComfyUI\", DEFAULT_COUNT)\n        assert result == f\"repo:^Comfy\\\\-Org/ComfyUI$ type:file count:{DEFAULT_COUNT} LoadImage\"\n\n    def test_with_repo_full_name(self):\n        result = _build_query(\"LoadImage\", \"Comfy-Org/ComfyUI\", DEFAULT_COUNT)\n        assert result == f\"repo:^Comfy\\\\-Org/ComfyUI$ type:file count:{DEFAULT_COUNT} LoadImage\"\n\n    def test_with_custom_count(self):\n        result = _build_query(\"LoadImage\", None, 50)\n        assert result == \"type:file count:50 LoadImage\"\n\n    def test_with_repo_and_count(self):\n        result = _build_query(\"LoadImage\", \"ComfyUI\", 100)\n        assert result == \"repo:^Comfy\\\\-Org/ComfyUI$ type:file count:100 LoadImage\"\n\n    def test_user_type_filter_preserved(self):\n        \"\"\"Don't inject type:file when the user already specified a type: filter.\"\"\"\n        result = _build_query(\"type:commit fix bug\", None, DEFAULT_COUNT)\n        assert \"type:file\" not in result\n        assert result == f\"count:{DEFAULT_COUNT} type:commit fix bug\"\n\n    def test_user_type_file_not_duplicated(self):\n        result = _build_query(\"type:file LoadImage\", None, DEFAULT_COUNT)\n        assert result.count(\"type:file\") == 1\n\n\n# ---------------------------------------------------------------------------\n# _format_results tests\n# ---------------------------------------------------------------------------\n\n\nclass TestFormatResults:\n    def test_formats_valid_results(self, search_response):\n        results = _format_results(search_response)\n        assert len(results) == 2\n\n        first = results[0]\n        assert first[\"repository\"] == \"Comfy-Org/ComfyUI\"\n        assert first[\"file\"] == \"nodes.py\"\n        assert first[\"branch\"] == \"main\"\n        assert first[\"commit\"] == \"abc123def456\"\n        assert first[\"file_url\"] == \"https://github.com/Comfy-Org/ComfyUI/blob/abc123def456/nodes.py\"\n        assert len(first[\"matches\"]) == 2\n\n        match = first[\"matches\"][0]\n        assert match[\"line\"] == 42  # lineNumber + 1\n        assert match[\"preview\"] == \"class LoadImage:\"\n        assert match[\"url\"] == f\"{first['file_url']}#L42\"\n\n    def test_empty_results(self, empty_search):\n        assert _format_results(empty_search) == []\n\n    def test_skips_results_without_repo(self):\n        search = {\"results\": {\"results\": [{\"__typename\": \"FileMatch\", \"repository\": None, \"file\": {\"path\": \"x.py\"}}]}}\n        assert _format_results(search) == []\n\n    def test_skips_results_without_file(self):\n        search = {\n            \"results\": {\n                \"results\": [\n                    {\"__typename\": \"FileMatch\", \"repository\": {\"name\": \"github.com/Comfy-Org/ComfyUI\"}, \"file\": None}\n                ]\n            }\n        }\n        assert _format_results(search) == []\n\n    def test_handles_missing_branch_info(self):\n        search = {\n            \"results\": {\n                \"results\": [\n                    {\n                        \"__typename\": \"FileMatch\",\n                        \"repository\": {\"name\": \"github.com/Comfy-Org/ComfyUI\", \"defaultBranch\": None},\n                        \"file\": {\"path\": \"test.py\"},\n                        \"lineMatches\": [{\"preview\": \"hello\", \"lineNumber\": 0, \"offsetAndLengths\": []}],\n                    }\n                ]\n            }\n        }\n        results = _format_results(search)\n        assert len(results) == 1\n        assert results[0][\"branch\"] == \"main\"\n        assert results[0][\"commit\"] == \"\"\n        assert \"blob/main/\" in results[0][\"matches\"][0][\"url\"]\n\n    def test_handles_completely_empty_response(self):\n        assert _format_results({}) == []\n\n    def test_handles_no_line_matches(self):\n        search = {\n            \"results\": {\n                \"results\": [\n                    {\n                        \"__typename\": \"FileMatch\",\n                        \"repository\": {\"name\": \"github.com/Comfy-Org/ComfyUI\", \"defaultBranch\": None},\n                        \"file\": {\"path\": \"test.py\"},\n                        \"lineMatches\": None,\n                    }\n                ]\n            }\n        }\n        results = _format_results(search)\n        assert len(results) == 1\n        assert results[0][\"matches\"] == []\n\n\n# ---------------------------------------------------------------------------\n# _get_stats tests\n# ---------------------------------------------------------------------------\n\n\nclass TestGetStats:\n    def test_extracts_stats(self, search_response):\n        stats = _get_stats(search_response)\n        assert stats[\"approximate_count\"] == \"42\"\n        assert stats[\"match_count\"] == 3\n        assert stats[\"limit_hit\"] is False\n\n    def test_empty_response(self):\n        stats = _get_stats({})\n        assert stats[\"approximate_count\"] == \"0\"\n        assert stats[\"match_count\"] == 0\n        assert stats[\"limit_hit\"] is False\n\n    def test_limit_hit(self, limit_hit_search):\n        stats = _get_stats(limit_hit_search)\n        assert stats[\"limit_hit\"] is True\n\n\n# ---------------------------------------------------------------------------\n# _fetch_results tests\n# ---------------------------------------------------------------------------\n\n\nclass TestFetchResults:\n    @patch(\"comfy_cli.command.code_search.requests.get\")\n    def test_successful_fetch(self, mock_get, raw_api_response):\n        mock_response = MagicMock()\n        mock_response.json.return_value = raw_api_response\n        mock_response.raise_for_status.return_value = None\n        mock_get.return_value = mock_response\n\n        result = _fetch_results(\"LoadImage\")\n\n        mock_get.assert_called_once_with(API_URL, params={\"query\": \"LoadImage\"}, timeout=REQUEST_TIMEOUT)\n        assert result == raw_api_response\n\n    @patch(\"comfy_cli.command.code_search.requests.get\")\n    def test_http_error_propagates(self, mock_get):\n        mock_response = MagicMock()\n        mock_response.raise_for_status.side_effect = requests.HTTPError(response=MagicMock(status_code=500))\n        mock_get.return_value = mock_response\n\n        with pytest.raises(requests.HTTPError):\n            _fetch_results(\"LoadImage\")\n\n    @patch(\"comfy_cli.command.code_search.requests.get\")\n    def test_timeout_propagates(self, mock_get):\n        mock_get.side_effect = requests.Timeout(\"timed out\")\n\n        with pytest.raises(requests.Timeout):\n            _fetch_results(\"LoadImage\")\n\n    @patch(\"comfy_cli.command.code_search.requests.get\")\n    def test_connection_error_propagates(self, mock_get):\n        mock_get.side_effect = requests.ConnectionError(\"no connection\")\n\n        with pytest.raises(requests.ConnectionError):\n            _fetch_results(\"LoadImage\")\n\n\n# ---------------------------------------------------------------------------\n# _print_results tests\n# ---------------------------------------------------------------------------\n\n\nclass TestPrintResults:\n    def test_json_output(self, capsys, search_response):\n        results = _format_results(search_response)\n        stats = _get_stats(search_response)\n        _print_results(results, stats, json_output=True)\n\n        output = capsys.readouterr().out\n        parsed = json.loads(output)\n        assert \"stats\" in parsed\n        assert \"results\" in parsed\n        assert len(parsed[\"results\"]) == 2\n\n    def test_empty_results_message(self, capsys):\n        _print_results([], {\"approximate_count\": \"0\", \"match_count\": 0, \"limit_hit\": False}, json_output=False)\n        output = capsys.readouterr().out\n        assert \"No results found\" in output\n\n    def test_formatted_output_contains_file_info(self, capsys, search_response):\n        results = _format_results(search_response)\n        stats = _get_stats(search_response)\n        _print_results(results, stats, json_output=False)\n\n        output = capsys.readouterr().out\n        assert \"Comfy-Org/ComfyUI\" in output\n        assert \"nodes.py\" in output\n        assert \"class LoadImage:\" in output\n\n    def test_limit_hit_message(self, capsys, limit_hit_search):\n        results = _format_results(limit_hit_search)\n        stats = _get_stats(limit_hit_search)\n        _print_results(results, stats, json_output=False)\n\n        output = capsys.readouterr().out\n        assert \"limit hit\" in output\n\n    def test_non_tty_prints_file_url_once_and_no_per_line_urls(self, capsys, search_response):\n        \"\"\"Non-TTY output: one URL per file, no per-match URLs, no OSC 8 escapes.\"\"\"\n        with patch(\"comfy_cli.command.code_search.sys.stdout.isatty\", return_value=False):\n            results = _format_results(search_response)\n            stats = _get_stats(search_response)\n            _print_results(results, stats, json_output=False)\n\n        output = capsys.readouterr().out\n        # File URL printed once per file (2 files in fixture).\n        assert output.count(\"https://github.com/Comfy-Org/ComfyUI/blob/abc123def456/nodes.py\") == 1\n        assert \"blob/abc123def456/nodes.py\" in output\n        assert \"blob/abc123def456/server.py\" in output\n        # Per-line anchors must NOT appear in non-TTY mode.\n        assert \"#L42\" not in output\n        assert \"#L56\" not in output\n        # No OSC 8 escape sequences.\n        assert \"\\x1b]8;\" not in output\n\n    def test_tty_emits_osc8_and_hides_urls(self, search_response):\n        \"\"\"TTY output: OSC 8 escapes present, URLs not shown as plain text.\"\"\"\n        import io\n        import re\n\n        from rich.console import Console\n\n        buf = io.StringIO()\n        fake_console = Console(file=buf, force_terminal=True, width=200, color_system=\"truecolor\")\n        with (\n            patch(\"comfy_cli.command.code_search.console\", fake_console),\n            patch(\"comfy_cli.command.code_search.sys.stdout.isatty\", return_value=True),\n        ):\n            results = _format_results(search_response)\n            stats = _get_stats(search_response)\n            _print_results(results, stats, json_output=False)\n\n        output = buf.getvalue()\n        # OSC 8 hyperlink sequences must be present.\n        assert \"\\x1b]8;\" in output\n\n        # Strip OSC 8 and SGR escape sequences to get visible text only.\n        visible = re.sub(r\"\\x1b\\][^\\x1b\\x07]*(?:\\x07|\\x1b\\\\)\", \"\", output)\n        visible = re.sub(r\"\\x1b\\[[0-9;]*m\", \"\", visible)\n\n        # Raw URLs must NOT appear in visible output (they're inside OSC 8 payloads).\n        assert \"https://github.com/\" not in visible\n        # Header and line content must be rendered.\n        assert \"Comfy-Org/ComfyUI / nodes.py\" in visible\n        assert \"L   42\" in visible\n        assert \"class LoadImage:\" in visible\n\n    def test_non_tty_ignores_force_color_env(self, capsys, search_response, monkeypatch):\n        \"\"\"FORCE_COLOR / TTY_COMPATIBLE must not leak OSC 8 into a piped stream.\"\"\"\n        monkeypatch.setenv(\"FORCE_COLOR\", \"1\")\n        monkeypatch.setenv(\"TTY_COMPATIBLE\", \"1\")\n        with patch(\"comfy_cli.command.code_search.sys.stdout.isatty\", return_value=False):\n            results = _format_results(search_response)\n            stats = _get_stats(search_response)\n            _print_results(results, stats, json_output=False)\n\n        output = capsys.readouterr().out\n        assert \"\\x1b]8;\" not in output\n\n\n# ---------------------------------------------------------------------------\n# CLI integration tests (via typer runner)\n# ---------------------------------------------------------------------------\n\n\nclass TestCodeSearchCLI:\n    @patch(\"comfy_cli.command.code_search._fetch_results\")\n    def test_basic_search(self, mock_fetch, raw_api_response):\n        mock_fetch.return_value = raw_api_response\n\n        result = runner.invoke(app, [\"LoadImage\"])\n\n        assert result.exit_code == 0\n        assert \"Comfy-Org/ComfyUI\" in result.output\n        mock_fetch.assert_called_once_with(f\"type:file count:{DEFAULT_COUNT} LoadImage\")\n\n    @patch(\"comfy_cli.command.code_search._fetch_results\")\n    def test_search_with_repo(self, mock_fetch, raw_api_response):\n        mock_fetch.return_value = raw_api_response\n\n        result = runner.invoke(app, [\"--repo\", \"ComfyUI\", \"LoadImage\"])\n\n        assert result.exit_code == 0\n        mock_fetch.assert_called_once_with(f\"repo:^Comfy\\\\-Org/ComfyUI$ type:file count:{DEFAULT_COUNT} LoadImage\")\n\n    @patch(\"comfy_cli.command.code_search._fetch_results\")\n    def test_search_with_count(self, mock_fetch, raw_api_response):\n        mock_fetch.return_value = raw_api_response\n\n        result = runner.invoke(app, [\"--count\", \"50\", \"LoadImage\"])\n\n        assert result.exit_code == 0\n        mock_fetch.assert_called_once_with(\"type:file count:50 LoadImage\")\n\n    @patch(\"comfy_cli.command.code_search._fetch_results\")\n    def test_search_json_output(self, mock_fetch, raw_api_response):\n        mock_fetch.return_value = raw_api_response\n\n        result = runner.invoke(app, [\"--json\", \"LoadImage\"])\n\n        assert result.exit_code == 0\n        parsed = json.loads(result.output)\n        assert \"results\" in parsed\n\n    @patch(\"comfy_cli.command.code_search._fetch_results\")\n    def test_search_no_results(self, mock_fetch, empty_api_response):\n        mock_fetch.return_value = empty_api_response\n\n        result = runner.invoke(app, [\"nonexistent_xyz_query\"])\n\n        assert result.exit_code == 0\n        assert \"No results found\" in result.output\n\n    @patch(\"comfy_cli.command.code_search._fetch_results\")\n    def test_connection_error(self, mock_fetch):\n        mock_fetch.side_effect = requests.ConnectionError(\"no connection\")\n\n        result = runner.invoke(app, [\"LoadImage\"])\n\n        assert result.exit_code == 1\n        assert \"Could not connect\" in result.output\n\n    @patch(\"comfy_cli.command.code_search._fetch_results\")\n    def test_timeout_error(self, mock_fetch):\n        mock_fetch.side_effect = requests.Timeout(\"timed out\")\n\n        result = runner.invoke(app, [\"LoadImage\"])\n\n        assert result.exit_code == 1\n        assert \"timed out\" in result.output\n\n    @patch(\"comfy_cli.command.code_search._fetch_results\")\n    def test_http_error(self, mock_fetch):\n        mock_response = MagicMock()\n        mock_response.status_code = 503\n        mock_fetch.side_effect = requests.HTTPError(response=mock_response)\n\n        result = runner.invoke(app, [\"LoadImage\"])\n\n        assert result.exit_code == 1\n        assert \"503\" in result.output\n\n    @patch(\"comfy_cli.command.code_search._fetch_results\")\n    def test_http_error_no_response(self, mock_fetch):\n        mock_fetch.side_effect = requests.HTTPError(response=None)\n\n        result = runner.invoke(app, [\"LoadImage\"])\n\n        assert result.exit_code == 1\n        assert \"unknown\" in result.output\n\n    @patch(\"comfy_cli.command.code_search._fetch_results\")\n    def test_short_options(self, mock_fetch, raw_api_response):\n        mock_fetch.return_value = raw_api_response\n\n        result = runner.invoke(app, [\"-r\", \"ComfyUI\", \"-n\", \"30\", \"-j\", \"LoadImage\"])\n\n        assert result.exit_code == 0\n        mock_fetch.assert_called_once_with(\"repo:^Comfy\\\\-Org/ComfyUI$ type:file count:30 LoadImage\")\n        parsed = json.loads(result.output)\n        assert \"results\" in parsed\n\n\n# ---------------------------------------------------------------------------\n# Root CLI wiring smoke tests\n# ---------------------------------------------------------------------------\n\n\nclass TestRootCLIWiring:\n    \"\"\"Smoke tests verifying code-search and cs alias are wired into the root app.\"\"\"\n\n    @patch(\"comfy_cli.tracking.prompt_tracking_consent\")\n    @patch(\"comfy_cli.cmdline.workspace_manager\")\n    @patch(\"comfy_cli.command.code_search._fetch_results\")\n    def test_code_search_registered(self, mock_fetch, mock_ws, mock_track, raw_api_response):\n        from comfy_cli.cmdline import app as root_app\n\n        mock_fetch.return_value = raw_api_response\n        result = runner.invoke(root_app, [\"code-search\", \"--json\", \"LoadImage\"])\n        assert result.exit_code == 0, result.output\n        parsed = json.loads(result.output)\n        assert \"results\" in parsed\n\n    @patch(\"comfy_cli.tracking.prompt_tracking_consent\")\n    @patch(\"comfy_cli.cmdline.workspace_manager\")\n    @patch(\"comfy_cli.command.code_search._fetch_results\")\n    def test_cs_alias_registered(self, mock_fetch, mock_ws, mock_track, raw_api_response):\n        from comfy_cli.cmdline import app as root_app\n\n        mock_fetch.return_value = raw_api_response\n        result = runner.invoke(root_app, [\"cs\", \"--json\", \"LoadImage\"])\n        assert result.exit_code == 0, result.output\n        parsed = json.loads(result.output)\n        assert \"results\" in parsed\n"
  },
  {
    "path": "tests/comfy_cli/command/test_command.py",
    "content": "import os\nfrom unittest.mock import patch\n\nimport pytest\nfrom typer.testing import CliRunner\n\nfrom comfy_cli.cmdline import app, g_exclusivity, g_gpu_exclusivity\n\n\n@pytest.fixture(scope=\"function\")\ndef runner():\n    g_exclusivity.reset_for_testing()\n    g_gpu_exclusivity.reset_for_testing()\n    return CliRunner()\n\n\n@pytest.fixture(scope=\"function\")\ndef mock_execute():\n    with patch(\"comfy_cli.command.install.execute\") as mock:\n        yield mock\n\n\n@pytest.fixture(scope=\"function\")\ndef mock_prompt_select_enum():\n    def mocked_prompt_select_enum(question: str, choices: list, force_prompting: bool = False):\n        return choices[0]\n\n    with patch(\n        \"comfy_cli.ui.prompt_select_enum\",\n        new=mocked_prompt_select_enum,\n    ) as mock:\n        yield mock\n\n\n@pytest.fixture(autouse=True)\ndef mock_tracking_consent():\n    with patch(\"comfy_cli.tracking.prompt_tracking_consent\"):\n        yield\n\n\n@pytest.mark.parametrize(\n    \"cmd\",\n    [\n        [\"--here\", \"install\"],\n        [\"--workspace\", \"./ComfyUI\", \"install\"],\n    ],\n)\ndef test_install_here(cmd, runner, mock_execute, mock_prompt_select_enum):\n    result = runner.invoke(app, cmd)\n    assert result.exit_code == 0, result.stdout\n\n    args, _ = mock_execute.call_args\n    url, comfy_path, *_ = args\n    assert url == \"https://github.com/comfyanonymous/ComfyUI\"\n    assert comfy_path == os.path.join(os.getcwd(), \"ComfyUI\")\n\n\ndef test_version(runner):\n    result = runner.invoke(app, [\"-v\"])\n    assert result.exit_code == 0\n    assert \"0.0.0\" in result.stdout\n\n\n@pytest.fixture\ndef mock_run_execute():\n    with patch(\"comfy_cli.command.run.execute\") as mock:\n        yield mock\n\n\ndef _write_workflow(tmp_path):\n    wf = tmp_path / \"wf.json\"\n    wf.write_text('{\"1\": {\"class_type\": \"X\", \"inputs\": {}}}')\n    return str(wf)\n\n\nclass TestRunApiKeyResolution:\n    \"\"\"typer envvar resolution: --api-key + COMFY_API_KEY must reach run.execute().\"\"\"\n\n    def test_envvar_is_picked_up(self, runner, mock_run_execute, tmp_path):\n        wf = _write_workflow(tmp_path)\n        result = runner.invoke(app, [\"run\", \"--workflow\", wf], env={\"COMFY_API_KEY\": \"env-key-xyz\"})\n        assert result.exit_code == 0, result.output\n        assert mock_run_execute.call_args.kwargs[\"api_key\"] == \"env-key-xyz\"\n\n    def test_flag_overrides_envvar(self, runner, mock_run_execute, tmp_path):\n        wf = _write_workflow(tmp_path)\n        result = runner.invoke(\n            app,\n            [\"run\", \"--workflow\", wf, \"--api-key\", \"flag-key-abc\"],\n            env={\"COMFY_API_KEY\": \"env-key-xyz\"},\n        )\n        assert result.exit_code == 0, result.output\n        assert mock_run_execute.call_args.kwargs[\"api_key\"] == \"flag-key-abc\"\n\n    def test_absent_resolves_to_none(self, runner, mock_run_execute, tmp_path):\n        wf = _write_workflow(tmp_path)\n        # Explicit empty env to neutralize any host-level COMFY_API_KEY leak.\n        result = runner.invoke(app, [\"run\", \"--workflow\", wf], env={\"COMFY_API_KEY\": \"\"})\n        assert result.exit_code == 0, result.output\n        assert mock_run_execute.call_args.kwargs[\"api_key\"] is None\n\n    def test_envvar_trailing_whitespace_is_stripped(self, runner, mock_run_execute, tmp_path):\n        wf = _write_workflow(tmp_path)\n        result = runner.invoke(app, [\"run\", \"--workflow\", wf], env={\"COMFY_API_KEY\": \"  sk-abc\\n\"})\n        assert result.exit_code == 0, result.output\n        assert mock_run_execute.call_args.kwargs[\"api_key\"] == \"sk-abc\"\n\n    def test_whitespace_only_collapses_to_none(self, runner, mock_run_execute, tmp_path):\n        wf = _write_workflow(tmp_path)\n        result = runner.invoke(app, [\"run\", \"--workflow\", wf], env={\"COMFY_API_KEY\": \"   \\n\\t\"})\n        assert result.exit_code == 0, result.output\n        assert mock_run_execute.call_args.kwargs[\"api_key\"] is None\n"
  },
  {
    "path": "tests/comfy_cli/command/test_frontend_pr.py",
    "content": "from unittest.mock import Mock, patch\n\nimport pytest\nfrom typer.testing import CliRunner\n\nfrom comfy_cli.command.install import (\n    PRInfo,\n    parse_frontend_pr_reference,\n    verify_node_tools,\n)\n\n\n@pytest.fixture\ndef runner():\n    return CliRunner()\n\n\n@pytest.fixture\ndef sample_frontend_pr_info():\n    return PRInfo(\n        number=456,\n        head_repo_url=\"https://github.com/testuser/ComfyUI_frontend.git\",\n        head_branch=\"feature-branch\",\n        base_repo_url=\"https://github.com/Comfy-Org/ComfyUI_frontend.git\",\n        base_branch=\"main\",\n        title=\"Add new feature to frontend\",\n        user=\"testuser\",\n        mergeable=True,\n    )\n\n\nclass TestFrontendPRReferenceParsing:\n    \"\"\"Test frontend PR reference parsing functionality\"\"\"\n\n    def test_parse_frontend_pr_number_format(self):\n        \"\"\"Test parsing #123 format for frontend\"\"\"\n        repo_owner, repo_name, pr_number = parse_frontend_pr_reference(\"#456\")\n        assert repo_owner == \"Comfy-Org\"\n        assert repo_name == \"ComfyUI_frontend\"\n        assert pr_number == 456\n\n    def test_parse_frontend_user_branch_format(self):\n        \"\"\"Test parsing username:branch format for frontend\"\"\"\n        repo_owner, repo_name, pr_number = parse_frontend_pr_reference(\"testuser:feature-branch\")\n        assert repo_owner == \"testuser\"\n        assert repo_name == \"ComfyUI_frontend\"\n        assert pr_number is None\n\n    def test_parse_frontend_github_url_format(self):\n        \"\"\"Test parsing full GitHub PR URL for frontend\"\"\"\n        url = \"https://github.com/Comfy-Org/ComfyUI_frontend/pull/789\"\n        repo_owner, repo_name, pr_number = parse_frontend_pr_reference(url)\n        assert repo_owner == \"Comfy-Org\"\n        assert repo_name == \"ComfyUI_frontend\"\n        assert pr_number == 789\n\n    def test_parse_frontend_custom_repo_url(self):\n        \"\"\"Test parsing URL from custom repository\"\"\"\n        url = \"https://github.com/customuser/customrepo/pull/123\"\n        repo_owner, repo_name, pr_number = parse_frontend_pr_reference(url)\n        assert repo_owner == \"customuser\"\n        assert repo_name == \"customrepo\"\n        assert pr_number == 123\n\n    def test_parse_frontend_invalid_format(self):\n        \"\"\"Test parsing invalid format raises ValueError\"\"\"\n        with pytest.raises(ValueError, match=\"Invalid PR reference format\"):\n            parse_frontend_pr_reference(\"invalid-format\")\n\n    def test_parse_frontend_empty_string(self):\n        \"\"\"Test parsing empty string raises ValueError\"\"\"\n        with pytest.raises(ValueError):\n            parse_frontend_pr_reference(\"\")\n\n\nclass TestNodeToolsVerification:\n    \"\"\"Test Node.js tools verification\"\"\"\n\n    @patch(\"subprocess.run\")\n    def test_verify_node_tools_success(self, mock_run):\n        \"\"\"Test successful Node.js, npm, and pnpm verification\"\"\"\n        # Mock successful node, npm, and pnpm commands\n        node_result = Mock()\n        node_result.returncode = 0\n        node_result.stdout = \"v18.0.0\"\n\n        npm_result = Mock()\n        npm_result.returncode = 0\n        npm_result.stdout = \"9.0.0\"\n\n        pnpm_result = Mock()\n        pnpm_result.returncode = 0\n        pnpm_result.stdout = \"8.0.0\"\n\n        mock_run.side_effect = [node_result, npm_result, pnpm_result]\n\n        assert verify_node_tools() is True\n        assert mock_run.call_count == 3\n\n    @patch(\"subprocess.run\")\n    def test_verify_node_tools_missing_node(self, mock_run):\n        \"\"\"Test when Node.js is not installed\"\"\"\n        node_result = Mock()\n        node_result.returncode = 1\n\n        mock_run.return_value = node_result\n\n        assert verify_node_tools() is False\n        mock_run.assert_called_once_with([\"node\", \"--version\"], capture_output=True, text=True, check=False)\n\n    @patch(\"subprocess.run\")\n    def test_verify_node_tools_missing_npm(self, mock_run):\n        \"\"\"Test when npm is not installed\"\"\"\n        node_result = Mock()\n        node_result.returncode = 0\n        node_result.stdout = \"v18.0.0\"\n\n        npm_result = Mock()\n        npm_result.returncode = 1\n\n        mock_run.side_effect = [node_result, npm_result]\n\n        assert verify_node_tools() is False\n        assert mock_run.call_count == 2\n\n    @patch(\"rich.prompt.Confirm.ask\")\n    @patch(\"subprocess.run\")\n    def test_verify_node_tools_auto_install_pnpm(self, mock_run, mock_confirm):\n        \"\"\"Test automatic pnpm installation when user agrees\"\"\"\n        # Mock successful node and npm\n        node_result = Mock()\n        node_result.returncode = 0\n        node_result.stdout = \"v18.0.0\"\n\n        npm_result = Mock()\n        npm_result.returncode = 0\n        npm_result.stdout = \"9.0.0\"\n\n        # Mock pnpm not found initially\n        pnpm_missing = Mock()\n        pnpm_missing.returncode = 1\n\n        # Mock successful pnpm installation\n        install_result = Mock()\n        install_result.returncode = 0\n\n        # Mock pnpm verification after install\n        pnpm_verify = Mock()\n        pnpm_verify.returncode = 0\n        pnpm_verify.stdout = \"8.0.0\"\n\n        mock_run.side_effect = [node_result, npm_result, pnpm_missing, install_result, pnpm_verify]\n        mock_confirm.return_value = True  # User agrees to install\n\n        assert verify_node_tools() is True\n        assert mock_run.call_count == 5\n        mock_confirm.assert_called_once()\n\n    @patch(\"rich.prompt.Confirm.ask\")\n    @patch(\"subprocess.run\")\n    def test_verify_node_tools_user_declines_pnpm_install(self, mock_run, mock_confirm):\n        \"\"\"Test when user declines pnpm installation\"\"\"\n        # Mock successful node and npm\n        node_result = Mock()\n        node_result.returncode = 0\n        node_result.stdout = \"v18.0.0\"\n\n        npm_result = Mock()\n        npm_result.returncode = 0\n        npm_result.stdout = \"9.0.0\"\n\n        # Mock pnpm not found\n        pnpm_missing = Mock()\n        pnpm_missing.returncode = 1\n\n        mock_run.side_effect = [node_result, npm_result, pnpm_missing]\n        mock_confirm.return_value = False  # User declines install\n\n        assert verify_node_tools() is False\n        assert mock_run.call_count == 3\n        mock_confirm.assert_called_once()\n\n    @patch(\"subprocess.run\")\n    def test_verify_node_tools_file_not_found(self, mock_run):\n        \"\"\"Test when commands are not found\"\"\"\n        mock_run.side_effect = FileNotFoundError(\"node not found\")\n\n        assert verify_node_tools() is False\n"
  },
  {
    "path": "tests/comfy_cli/command/test_launch_frontend_pr.py",
    "content": "\"\"\"Tests for launch-time frontend PR functionality\"\"\"\n\nimport json\nfrom datetime import datetime, timedelta\nfrom pathlib import Path\nfrom unittest.mock import Mock, patch\n\nimport pytest\nfrom typer.testing import CliRunner\n\nfrom comfy_cli.cmdline import app\nfrom comfy_cli.command.install import PRInfo, handle_temporary_frontend_pr\nfrom comfy_cli.pr_cache import PRCache\n\n\n@pytest.fixture\ndef runner():\n    return CliRunner()\n\n\n@pytest.fixture(autouse=True)\ndef mock_tracking_consent():\n    with patch(\"comfy_cli.tracking.prompt_tracking_consent\"):\n        yield\n\n\n@pytest.fixture\ndef sample_frontend_pr_info():\n    return PRInfo(\n        number=789,\n        head_repo_url=\"https://github.com/testuser/ComfyUI_frontend.git\",\n        head_branch=\"test-feature\",\n        base_repo_url=\"https://github.com/Comfy-Org/ComfyUI_frontend.git\",\n        base_branch=\"main\",\n        title=\"Test feature for frontend\",\n        user=\"testuser\",\n        mergeable=True,\n    )\n\n\n@pytest.fixture\ndef mock_pr_cache():\n    with patch(\"comfy_cli.pr_cache.PRCache\") as mock_cache_cls:\n        mock_cache = Mock()\n        mock_cache_cls.return_value = mock_cache\n        yield mock_cache\n\n\nclass TestLaunchWithFrontendPR:\n    \"\"\"Test launching with temporary frontend PR\"\"\"\n\n    @patch(\"comfy_cli.command.install.verify_node_tools\")\n    def test_launch_frontend_pr_without_node(self, mock_verify):\n        \"\"\"Test launch with frontend PR when Node.js is missing\"\"\"\n        mock_verify.return_value = False\n\n        result = handle_temporary_frontend_pr(\"#123\")\n        assert result is None\n        mock_verify.assert_called_once()\n\n    @patch(\"comfy_cli.command.install.verify_node_tools\")\n    @patch(\"comfy_cli.command.install.parse_frontend_pr_reference\")\n    @patch(\"comfy_cli.command.install.fetch_pr_info\")\n    def test_launch_frontend_pr_with_cache_hit(\n        self, mock_fetch, mock_parse, mock_verify, mock_pr_cache, sample_frontend_pr_info\n    ):\n        \"\"\"Test launch with cached frontend PR\"\"\"\n        mock_verify.return_value = True\n        mock_parse.return_value = (\"Comfy-Org\", \"ComfyUI_frontend\", 789)\n        mock_fetch.return_value = sample_frontend_pr_info\n\n        # Mock cache hit\n        cached_path = Path(\"/cache/frontend/pr-789/dist\")\n        mock_pr_cache.get_cached_frontend_path.return_value = cached_path\n\n        result = handle_temporary_frontend_pr(\"#789\")\n\n        assert result == str(cached_path)\n        mock_pr_cache.get_cached_frontend_path.assert_called_once()\n        # Should not build if cache hit\n        mock_pr_cache.save_cache_info.assert_not_called()\n\n    @patch(\"pathlib.Path.mkdir\")\n    @patch(\"os.chdir\")\n    @patch(\"subprocess.run\")\n    @patch(\"comfy_cli.command.install.checkout_pr\")\n    @patch(\"comfy_cli.command.install.clone_comfyui\")\n    @patch(\"comfy_cli.command.install.verify_node_tools\")\n    @patch(\"comfy_cli.command.install.parse_frontend_pr_reference\")\n    @patch(\"comfy_cli.command.install.fetch_pr_info\")\n    def test_launch_frontend_pr_cache_miss_builds(\n        self,\n        mock_fetch,\n        mock_parse,\n        mock_verify,\n        mock_clone,\n        mock_checkout,\n        mock_run,\n        mock_chdir,\n        mock_mkdir,\n        mock_pr_cache,\n        sample_frontend_pr_info,\n    ):\n        \"\"\"Test launch builds frontend when not cached\"\"\"\n        mock_verify.return_value = True\n        mock_parse.return_value = (\"Comfy-Org\", \"ComfyUI_frontend\", 789)\n        mock_fetch.return_value = sample_frontend_pr_info\n        mock_checkout.return_value = True\n\n        # Mock cache miss\n        mock_pr_cache.get_cached_frontend_path.return_value = None\n        cache_path = Path(\"/cache/frontend/pr-789\")\n        mock_pr_cache.get_frontend_cache_path.return_value = cache_path\n\n        # Mock successful build\n        mock_run.side_effect = [\n            Mock(returncode=0),  # pnpm install\n            Mock(returncode=0),  # vite build\n        ]\n\n        # Mock dist exists\n        with patch(\"pathlib.Path.exists\") as mock_exists:\n            mock_exists.return_value = True\n\n            result = handle_temporary_frontend_pr(\"#789\")\n\n        # Should return built path\n        assert result == str(cache_path / \"repo\" / \"dist\")\n        # Should save cache info\n        mock_pr_cache.save_cache_info.assert_called_once_with(sample_frontend_pr_info, cache_path)\n\n\nclass TestPRCacheManagement:\n    \"\"\"Test PR cache functionality\"\"\"\n\n    def test_pr_cache_get_frontend_path(self, sample_frontend_pr_info):\n        \"\"\"Test getting frontend cache path\"\"\"\n        cache = PRCache()\n        path = cache.get_frontend_cache_path(sample_frontend_pr_info)\n\n        assert \"frontend\" in str(path)\n        assert \"testuser\" in str(path)\n        assert \"789\" in str(path)\n\n    def test_pr_cache_list_empty(self):\n        \"\"\"Test listing empty cache\"\"\"\n        cache = PRCache()\n        with patch(\"pathlib.Path.exists\", return_value=False):\n            result = cache.list_cached_frontends()\n        assert result == []\n\n    def test_pr_cache_clean_specific(self, tmp_path):\n        \"\"\"Test cleaning specific PR cache\"\"\"\n        cache = PRCache()\n        cache.cache_dir = tmp_path / \"test-cache\"\n\n        # Create mock cache structure\n        frontend_cache = cache.cache_dir / \"frontend\" / \"pr-123\"\n        frontend_cache.mkdir(parents=True)\n        cache_info = frontend_cache / \".cache-info.json\"\n        cache_info.write_text('{\"pr_number\": 123}')\n\n        # Clean specific PR\n        cache.clean_frontend_cache(123)\n\n        assert not frontend_cache.exists()\n\n    def test_pr_cache_age_check(self, sample_frontend_pr_info, tmp_path):\n        \"\"\"Test cache age validation\"\"\"\n        cache = PRCache()\n        cache.cache_dir = tmp_path / \"test-cache\"\n        cache_path = cache.get_frontend_cache_path(sample_frontend_pr_info)\n        cache_path.mkdir(parents=True)\n\n        # Create cache info with old timestamp\n        old_time = datetime.now() - timedelta(days=10)\n        cache_info = {\n            \"pr_number\": sample_frontend_pr_info.number,\n            \"pr_title\": sample_frontend_pr_info.title,\n            \"user\": sample_frontend_pr_info.user,\n            \"head_branch\": sample_frontend_pr_info.head_branch,\n            \"cached_at\": old_time.isoformat(),\n        }\n\n        info_path = cache.get_cache_info_path(cache_path)\n        with open(info_path, \"w\") as f:\n            json.dump(cache_info, f)\n\n        # Should be invalid due to age\n        assert not cache.is_cache_valid(sample_frontend_pr_info, cache_path)\n\n    def test_pr_cache_enforce_limits(self, tmp_path):\n        \"\"\"Test cache limit enforcement\"\"\"\n        cache = PRCache()\n        cache.cache_dir = tmp_path / \"test-cache\"\n        cache.max_cache_items = 3  # Set low limit for testing\n\n        # Create multiple cache entries\n        for i in range(5):\n            cache_dir = cache.cache_dir / \"frontend\" / f\"pr-{i}\"\n            cache_dir.mkdir(parents=True)\n            cache_info = {\n                \"pr_number\": i,\n                \"pr_title\": f\"Test PR {i}\",\n                \"cached_at\": (datetime.now() - timedelta(hours=i)).isoformat(),\n            }\n            with open(cache_dir / \".cache-info.json\", \"w\") as f:\n                json.dump(cache_info, f)\n\n        # Enforce limits\n        cache.enforce_cache_limits()\n\n        # Should only have 3 newest items\n        remaining = list((cache.cache_dir / \"frontend\").iterdir())\n        assert len(remaining) == 3\n        # Check that newest items remain\n        remaining_numbers = sorted([int(d.name.split(\"-\")[1]) for d in remaining])\n        assert remaining_numbers == [0, 1, 2]  # Newest 3\n\n    def test_get_cache_age(self):\n        \"\"\"Test human-readable cache age\"\"\"\n        cache = PRCache()\n\n        # Test various ages\n        now = datetime.now()\n        assert cache.get_cache_age(now.isoformat()) == \"just now\"\n\n        age_5_min = (now - timedelta(minutes=5)).isoformat()\n        assert \"5 minutes ago\" in cache.get_cache_age(age_5_min)\n\n        age_2_hours = (now - timedelta(hours=2)).isoformat()\n        assert \"2 hours ago\" in cache.get_cache_age(age_2_hours)\n\n        age_3_days = (now - timedelta(days=3)).isoformat()\n        assert \"3 days ago\" in cache.get_cache_age(age_3_days)\n\n\nclass TestPRCacheCommands:\n    \"\"\"Test PR cache CLI commands\"\"\"\n\n    def test_pr_cache_list_command(self, runner):\n        \"\"\"Test pr-cache list command\"\"\"\n        with patch(\"comfy_cli.command.pr_command.PRCache\") as mock_cache_cls:\n            mock_cache = Mock()\n            mock_cache.list_cached_frontends.return_value = []\n            mock_cache_cls.return_value = mock_cache\n\n            result = runner.invoke(app, [\"pr-cache\", \"list\"])\n            assert result.exit_code == 0\n            assert \"No cached PR builds found\" in result.output\n\n    def test_pr_cache_clean_command_with_confirmation(self, runner):\n        \"\"\"Test pr-cache clean command with confirmation\"\"\"\n        with patch(\"comfy_cli.command.pr_command.PRCache\") as mock_cache_cls:\n            mock_cache = Mock()\n            mock_cache.list_cached_frontends.return_value = [\n                {\"pr_number\": 123, \"pr_title\": \"Test PR\"}  # Mock some cached items\n            ]\n            mock_cache_cls.return_value = mock_cache\n\n            # Simulate user saying \"no\"\n            result = runner.invoke(app, [\"pr-cache\", \"clean\"], input=\"n\\n\")\n            assert result.exit_code == 0\n            assert \"Cancelled\" in result.output\n            mock_cache.clean_frontend_cache.assert_not_called()\n\n    def test_pr_cache_clean_command_with_yes_flag(self, runner):\n        \"\"\"Test pr-cache clean command with --yes flag\"\"\"\n        with patch(\"comfy_cli.command.pr_command.PRCache\") as mock_cache_cls:\n            mock_cache = Mock()\n            mock_cache_cls.return_value = mock_cache\n\n            result = runner.invoke(app, [\"pr-cache\", \"clean\", \"--yes\"])\n            assert result.exit_code == 0\n            mock_cache.clean_frontend_cache.assert_called_once_with()\n"
  },
  {
    "path": "tests/comfy_cli/command/test_manager_gui.py",
    "content": "from unittest.mock import MagicMock, patch\n\nimport pytest\nimport typer\n\nfrom comfy_cli import constants\nfrom comfy_cli.command.launch import _get_manager_flags\n\n\n@pytest.fixture()\ndef mock_config_manager():\n    with patch(\"comfy_cli.command.custom_nodes.command.ConfigManager\") as mock_cls:\n        instance = MagicMock()\n        mock_cls.return_value = instance\n        yield instance\n\n\n@pytest.fixture()\ndef mock_launch_config_manager():\n    with patch(\"comfy_cli.command.launch.ConfigManager\") as mock_cls:\n        instance = MagicMock()\n        mock_cls.return_value = instance\n        yield instance\n\n\nclass TestManagerCommands:\n    def test_disable_manager_sets_config(self, mock_config_manager):\n        from comfy_cli.command.custom_nodes.command import disable_manager\n\n        disable_manager()\n\n        mock_config_manager.set.assert_called_once_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, \"disable\")\n\n    def test_enable_gui_sets_config(self, mock_config_manager):\n        from comfy_cli.command.custom_nodes.command import enable_gui\n\n        enable_gui()\n\n        mock_config_manager.set.assert_called_once_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, \"enable-gui\")\n\n    def test_disable_gui_sets_config(self, mock_config_manager):\n        from comfy_cli.command.custom_nodes.command import disable_gui\n\n        disable_gui()\n\n        mock_config_manager.set.assert_called_once_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, \"disable-gui\")\n\n    def test_enable_legacy_gui_sets_config(self, mock_config_manager):\n        from comfy_cli.command.custom_nodes.command import enable_legacy_gui\n\n        enable_legacy_gui()\n\n        mock_config_manager.set.assert_called_once_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, \"enable-legacy-gui\")\n\n\nclass TestGetManagerFlags:\n    @patch(\"comfy_cli.command.launch.resolve_manager_gui_mode\", return_value=\"disable\")\n    def test_disable_mode_returns_empty(self, mock_resolve):\n        result = _get_manager_flags()\n        assert result == []\n\n    @patch(\"comfy_cli.command.launch.find_cm_cli\", return_value=True)\n    @patch(\"comfy_cli.command.launch.resolve_manager_gui_mode\", return_value=\"enable-gui\")\n    def test_enable_gui_mode_returns_enable_manager(self, mock_resolve, mock_find):\n        result = _get_manager_flags()\n        assert result == [\"--enable-manager\"]\n\n    @patch(\"comfy_cli.command.launch.find_cm_cli\", return_value=True)\n    @patch(\"comfy_cli.command.launch.resolve_manager_gui_mode\", return_value=\"disable-gui\")\n    def test_disable_gui_mode_returns_both_flags(self, mock_resolve, mock_find):\n        result = _get_manager_flags()\n        assert result == [\"--enable-manager\", \"--disable-manager-ui\"]\n\n    @patch(\"comfy_cli.command.launch.find_cm_cli\", return_value=True)\n    @patch(\"comfy_cli.command.launch.resolve_manager_gui_mode\", return_value=\"enable-legacy-gui\")\n    def test_enable_legacy_gui_mode_returns_legacy_flags(self, mock_resolve, mock_find):\n        result = _get_manager_flags()\n        assert result == [\"--enable-manager\", \"--enable-manager-legacy-ui\"]\n\n    @patch(\"comfy_cli.command.launch.find_cm_cli\", return_value=True)\n    @patch(\"comfy_cli.command.launch.resolve_manager_gui_mode\", return_value=\"unknown-mode\")\n    def test_unknown_mode_returns_default_with_warning(self, mock_resolve, mock_find, capsys):\n        result = _get_manager_flags()\n        assert result == [\"--enable-manager\"]\n        captured = capsys.readouterr()\n        assert \"unknown-mode\" in captured.out.lower() or \"Unknown manager mode\" in captured.out\n\n    @patch(\"comfy_cli.command.launch.find_cm_cli\", return_value=False)\n    @patch(\"comfy_cli.command.launch.resolve_manager_gui_mode\", return_value=\"enable-gui\")\n    def test_enable_mode_without_cmcli_returns_empty(self, mock_resolve, mock_find):\n        \"\"\"When config is enable-* but cm-cli is not available, return empty list.\"\"\"\n        result = _get_manager_flags()\n        assert result == []\n        mock_find.assert_called_once()\n\n    @patch(\"comfy_cli.command.launch.resolve_manager_gui_mode\", return_value=None)\n    def test_not_installed_returns_empty(self, mock_resolve):\n        \"\"\"When resolve returns None (not installed), return empty list.\"\"\"\n        result = _get_manager_flags()\n        assert result == []\n\n\nclass TestResolveManagerGuiMode:\n    \"\"\"Tests for resolve_manager_gui_mode shared helper.\"\"\"\n\n    @patch(\"comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager\")\n    def test_returns_configured_mode(self, mock_cm_cls):\n        instance = MagicMock()\n        mock_cm_cls.return_value = instance\n        instance.get.return_value = \"disable-gui\"\n\n        from comfy_cli.command.custom_nodes.cm_cli_util import resolve_manager_gui_mode\n\n        assert resolve_manager_gui_mode() == \"disable-gui\"\n\n    @patch(\"comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli\", return_value=True)\n    @patch(\"comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager\")\n    def test_old_config_false_migrates_to_disable(self, mock_cm_cls, mock_find):\n        instance = MagicMock()\n        mock_cm_cls.return_value = instance\n        instance.get.side_effect = lambda key: {\n            constants.CONFIG_KEY_MANAGER_GUI_MODE: None,\n            constants.CONFIG_KEY_MANAGER_GUI_ENABLED: \"False\",\n        }.get(key)\n\n        from comfy_cli.command.custom_nodes.cm_cli_util import resolve_manager_gui_mode\n\n        assert resolve_manager_gui_mode() == \"disable\"\n\n    @patch(\"comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli\", return_value=True)\n    @patch(\"comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager\")\n    def test_old_config_true_migrates_to_enable_gui(self, mock_cm_cls, mock_find):\n        instance = MagicMock()\n        mock_cm_cls.return_value = instance\n        instance.get.side_effect = lambda key: {\n            constants.CONFIG_KEY_MANAGER_GUI_MODE: None,\n            constants.CONFIG_KEY_MANAGER_GUI_ENABLED: \"True\",\n        }.get(key)\n\n        from comfy_cli.command.custom_nodes.cm_cli_util import resolve_manager_gui_mode\n\n        assert resolve_manager_gui_mode() == \"enable-gui\"\n\n    @patch(\"comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli\", return_value=True)\n    @patch(\"comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager\")\n    def test_no_config_with_cmcli_defaults_to_enable_gui(self, mock_cm_cls, mock_find):\n        instance = MagicMock()\n        mock_cm_cls.return_value = instance\n        instance.get.return_value = None\n\n        from comfy_cli.command.custom_nodes.cm_cli_util import resolve_manager_gui_mode\n\n        assert resolve_manager_gui_mode() == \"enable-gui\"\n\n    @patch(\"comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli\", return_value=False)\n    @patch(\"comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager\")\n    def test_no_config_no_cmcli_returns_not_installed_value(self, mock_cm_cls, mock_find):\n        instance = MagicMock()\n        mock_cm_cls.return_value = instance\n        instance.get.return_value = None\n\n        from comfy_cli.command.custom_nodes.cm_cli_util import resolve_manager_gui_mode\n\n        assert resolve_manager_gui_mode(not_installed_value=None) is None\n        assert resolve_manager_gui_mode(not_installed_value=\"not-installed\") == \"not-installed\"\n\n    @patch(\"comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager\")\n    def test_old_config_boolean_false_migrates_to_disable(self, mock_cm_cls):\n        \"\"\"Test backward compatibility with actual boolean False value.\"\"\"\n        instance = MagicMock()\n        mock_cm_cls.return_value = instance\n        instance.get.side_effect = lambda key: {\n            constants.CONFIG_KEY_MANAGER_GUI_MODE: None,\n            constants.CONFIG_KEY_MANAGER_GUI_ENABLED: False,\n        }.get(key)\n\n        from comfy_cli.command.custom_nodes.cm_cli_util import resolve_manager_gui_mode\n\n        assert resolve_manager_gui_mode() == \"disable\"\n\n    @patch(\"comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli\", return_value=True)\n    @patch(\"comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager\")\n    def test_old_config_boolean_true_migrates_to_enable_gui(self, mock_cm_cls, mock_find):\n        \"\"\"Test backward compatibility with actual boolean True value.\"\"\"\n        instance = MagicMock()\n        mock_cm_cls.return_value = instance\n        instance.get.side_effect = lambda key: {\n            constants.CONFIG_KEY_MANAGER_GUI_MODE: None,\n            constants.CONFIG_KEY_MANAGER_GUI_ENABLED: True,\n        }.get(key)\n\n        from comfy_cli.command.custom_nodes.cm_cli_util import resolve_manager_gui_mode\n\n        assert resolve_manager_gui_mode() == \"enable-gui\"\n\n\nclass TestLaunchManagerFlagInjection:\n    @patch(\"comfy_cli.command.launch.launch_comfyui\")\n    @patch(\"comfy_cli.command.launch._get_manager_flags\", return_value=[\"--enable-manager\"])\n    @patch(\"comfy_cli.command.launch.workspace_manager\")\n    @patch(\"comfy_cli.command.launch.check_for_updates\")\n    @patch(\"os.chdir\")\n    def test_launch_injects_enable_manager(\n        self, mock_chdir, mock_updates, mock_ws, mock_get_flags, mock_launch_comfyui\n    ):\n        mock_ws.workspace_path = \"/fake/workspace\"\n        mock_ws.workspace_type = \"default\"\n        mock_ws.config_manager.config = {\"DEFAULT\": {}}\n\n        from comfy_cli.command.launch import launch\n\n        launch(background=False, extra=[\"--port\", \"8188\"])\n\n        args, kwargs = mock_launch_comfyui.call_args\n        extra_arg = args[0]\n        assert \"--enable-manager\" in extra_arg\n        assert \"--port\" in extra_arg\n\n    @patch(\"comfy_cli.command.launch.launch_comfyui\")\n    @patch(\"comfy_cli.command.launch._get_manager_flags\", return_value=[])\n    @patch(\"comfy_cli.command.launch.workspace_manager\")\n    @patch(\"comfy_cli.command.launch.check_for_updates\")\n    @patch(\"os.chdir\")\n    def test_launch_no_inject_when_disabled(\n        self, mock_chdir, mock_updates, mock_ws, mock_get_flags, mock_launch_comfyui\n    ):\n        mock_ws.workspace_path = \"/fake/workspace\"\n        mock_ws.workspace_type = \"default\"\n        mock_ws.config_manager.config = {\"DEFAULT\": {}}\n\n        from comfy_cli.command.launch import launch\n\n        launch(background=False, extra=[\"--port\", \"8188\"])\n\n        args, kwargs = mock_launch_comfyui.call_args\n        extra_arg = args[0]\n        assert \"--enable-manager\" not in extra_arg\n\n    @patch(\"comfy_cli.command.launch.launch_comfyui\")\n    @patch(\"comfy_cli.command.launch._get_manager_flags\", return_value=[\"--enable-manager\"])\n    @patch(\"comfy_cli.command.launch.workspace_manager\")\n    @patch(\"comfy_cli.command.launch.check_for_updates\")\n    @patch(\"os.chdir\")\n    def test_launch_injects_when_extra_is_none(\n        self, mock_chdir, mock_updates, mock_ws, mock_get_flags, mock_launch_comfyui\n    ):\n        mock_ws.workspace_path = \"/fake/workspace\"\n        mock_ws.workspace_type = \"not_default\"\n        mock_ws.config_manager.config = {\"DEFAULT\": {}}\n\n        from comfy_cli.command.launch import launch\n\n        launch(background=False, extra=None)\n\n        args, kwargs = mock_launch_comfyui.call_args\n        extra_arg = args[0]\n        assert extra_arg == [\"--enable-manager\"]\n\n    @patch(\"comfy_cli.command.launch.launch_comfyui\")\n    @patch(\"comfy_cli.command.launch._get_manager_flags\", return_value=[\"--enable-manager\", \"--disable-manager-ui\"])\n    @patch(\"comfy_cli.command.launch.workspace_manager\")\n    @patch(\"comfy_cli.command.launch.check_for_updates\")\n    @patch(\"os.chdir\")\n    def test_launch_injects_disable_gui_flags(\n        self, mock_chdir, mock_updates, mock_ws, mock_get_flags, mock_launch_comfyui\n    ):\n        mock_ws.workspace_path = \"/fake/workspace\"\n        mock_ws.workspace_type = \"not_default\"\n        mock_ws.config_manager.config = {\"DEFAULT\": {}}\n\n        from comfy_cli.command.launch import launch\n\n        launch(background=False, extra=None)\n\n        args, kwargs = mock_launch_comfyui.call_args\n        extra_arg = args[0]\n        assert \"--enable-manager\" in extra_arg\n        assert \"--disable-manager-ui\" in extra_arg\n\n    @patch(\"comfy_cli.command.launch.launch_comfyui\")\n    @patch(\n        \"comfy_cli.command.launch._get_manager_flags\", return_value=[\"--enable-manager\", \"--enable-manager-legacy-ui\"]\n    )\n    @patch(\"comfy_cli.command.launch.workspace_manager\")\n    @patch(\"comfy_cli.command.launch.check_for_updates\")\n    @patch(\"os.chdir\")\n    def test_launch_injects_legacy_gui_flags(\n        self, mock_chdir, mock_updates, mock_ws, mock_get_flags, mock_launch_comfyui\n    ):\n        mock_ws.workspace_path = \"/fake/workspace\"\n        mock_ws.workspace_type = \"not_default\"\n        mock_ws.config_manager.config = {\"DEFAULT\": {}}\n\n        from comfy_cli.command.launch import launch\n\n        launch(background=False, extra=None)\n\n        args, kwargs = mock_launch_comfyui.call_args\n        extra_arg = args[0]\n        assert \"--enable-manager\" in extra_arg\n        assert \"--enable-manager-legacy-ui\" in extra_arg\n\n\nclass TestMigrateLegacy:\n    @patch(\"comfy_cli.command.custom_nodes.command.workspace_manager\")\n    def test_migrate_legacy_no_workspace_exits(self, mock_ws, mock_config_manager):\n        \"\"\"When workspace is not set, migrate-legacy should exit with error.\"\"\"\n        mock_ws.workspace_path = None\n\n        from comfy_cli.command.custom_nodes.command import migrate_legacy\n\n        with pytest.raises(typer.Exit) as exc_info:\n            migrate_legacy(yes=True)\n\n        assert exc_info.value.exit_code == 1\n        mock_config_manager.set.assert_not_called()\n\n    @patch(\"comfy_cli.command.custom_nodes.command.workspace_manager\")\n    def test_migrate_legacy_with_cli_only_mode(self, mock_ws, mock_config_manager, tmp_path):\n        # Setup: create legacy manager with .enable-cli-only-mode and .git\n        custom_nodes = tmp_path / \"custom_nodes\"\n        legacy_manager = custom_nodes / \"ComfyUI-Manager\"\n        legacy_manager.mkdir(parents=True)\n        (legacy_manager / \".git\").mkdir()\n        (legacy_manager / \".enable-cli-only-mode\").touch()\n\n        mock_ws.workspace_path = str(tmp_path)\n\n        from comfy_cli.command.custom_nodes.command import migrate_legacy\n\n        migrate_legacy(yes=True)\n\n        mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, \"disable\")\n        # Verify moved to .disabled\n        assert not legacy_manager.exists()\n        assert (custom_nodes / \".disabled\" / \"ComfyUI-Manager\").exists()\n\n    @patch(\"comfy_cli.command.custom_nodes.command.subprocess.run\")\n    @patch(\"comfy_cli.command.custom_nodes.command.workspace_manager\")\n    def test_migrate_legacy_without_cli_only_mode(self, mock_ws, mock_subprocess_run, mock_config_manager, tmp_path):\n        # Setup: create legacy manager with .git but without .enable-cli-only-mode\n        custom_nodes = tmp_path / \"custom_nodes\"\n        legacy_manager = custom_nodes / \"ComfyUI-Manager\"\n        legacy_manager.mkdir(parents=True)\n        (legacy_manager / \".git\").mkdir()\n        # Create manager_requirements.txt for successful install\n        (tmp_path / constants.MANAGER_REQUIREMENTS_FILE).write_text(\"comfyui-manager>=4.1b1\")\n\n        mock_ws.workspace_path = str(tmp_path)\n        mock_subprocess_run.return_value = MagicMock(returncode=0)\n\n        from comfy_cli.command.custom_nodes.command import migrate_legacy\n\n        migrate_legacy(yes=True)\n\n        mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, \"enable-gui\")\n        # Verify moved to .disabled\n        assert not legacy_manager.exists()\n        assert (custom_nodes / \".disabled\" / \"ComfyUI-Manager\").exists()\n\n    @patch(\"comfy_cli.command.custom_nodes.command.workspace_manager\")\n    def test_migrate_legacy_no_legacy_manager(self, mock_ws, mock_config_manager, tmp_path):\n        # Setup: no legacy manager\n        custom_nodes = tmp_path / \"custom_nodes\"\n        custom_nodes.mkdir(parents=True)\n\n        mock_ws.workspace_path = str(tmp_path)\n\n        from comfy_cli.command.custom_nodes.command import migrate_legacy\n\n        migrate_legacy(yes=True)\n\n        # Should not call set when no legacy manager found\n        mock_config_manager.set.assert_not_called()\n\n    @patch(\"comfy_cli.command.custom_nodes.command.workspace_manager\")\n    def test_migrate_legacy_target_exists(self, mock_ws, mock_config_manager, tmp_path):\n        # Setup: both source and target exist\n        custom_nodes = tmp_path / \"custom_nodes\"\n        legacy_manager = custom_nodes / \"ComfyUI-Manager\"\n        legacy_manager.mkdir(parents=True)\n        (legacy_manager / \".git\").mkdir()\n        (custom_nodes / \".disabled\" / \"ComfyUI-Manager\").mkdir(parents=True)\n\n        mock_ws.workspace_path = str(tmp_path)\n\n        from comfy_cli.command.custom_nodes.command import migrate_legacy\n\n        with pytest.raises(typer.Exit):\n            migrate_legacy(yes=True)\n\n    @patch(\"comfy_cli.command.custom_nodes.command.subprocess.run\")\n    @patch(\"comfy_cli.command.custom_nodes.command.workspace_manager\")\n    def test_migrate_legacy_lowercase_directory(self, mock_ws, mock_subprocess_run, mock_config_manager, tmp_path):\n        # Setup: create legacy manager with lowercase name and .git\n        custom_nodes = tmp_path / \"custom_nodes\"\n        legacy_manager = custom_nodes / \"comfyui-manager\"  # lowercase\n        legacy_manager.mkdir(parents=True)\n        (legacy_manager / \".git\").mkdir()\n        # Create manager_requirements.txt for successful install\n        (tmp_path / constants.MANAGER_REQUIREMENTS_FILE).write_text(\"comfyui-manager>=4.1b1\")\n\n        mock_ws.workspace_path = str(tmp_path)\n        mock_subprocess_run.return_value = MagicMock(returncode=0)\n\n        from comfy_cli.command.custom_nodes.command import migrate_legacy\n\n        migrate_legacy(yes=True)\n\n        mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, \"enable-gui\")\n        # Verify moved to .disabled (preserving original case)\n        assert not legacy_manager.exists()\n        assert (custom_nodes / \".disabled\" / \"comfyui-manager\").exists()\n\n    @patch(\"comfy_cli.command.custom_nodes.command.resolve_workspace_python\", return_value=\"/workspace/venv/python\")\n    @patch(\"comfy_cli.command.custom_nodes.command.subprocess.run\")\n    @patch(\"comfy_cli.command.custom_nodes.command.workspace_manager\")\n    def test_migrate_legacy_installs_manager_requirements(\n        self, mock_ws, mock_subprocess_run, mock_resolve_python, mock_config_manager, tmp_path\n    ):\n        # Setup: create legacy manager with .git and manager_requirements.txt\n        custom_nodes = tmp_path / \"custom_nodes\"\n        legacy_manager = custom_nodes / \"ComfyUI-Manager\"\n        legacy_manager.mkdir(parents=True)\n        (legacy_manager / \".git\").mkdir()\n        # Create manager_requirements.txt in workspace root\n        (tmp_path / constants.MANAGER_REQUIREMENTS_FILE).write_text(\"comfyui-manager>=4.1b1\")\n\n        mock_ws.workspace_path = str(tmp_path)\n        mock_subprocess_run.return_value = MagicMock(returncode=0)\n\n        from comfy_cli.command.custom_nodes.command import migrate_legacy\n\n        migrate_legacy(yes=True)\n\n        # Verify pip install was called with workspace Python, NOT sys.executable\n        mock_subprocess_run.assert_called_once()\n        call_args = mock_subprocess_run.call_args[0][0]\n        assert call_args[0] == \"/workspace/venv/python\"\n        assert \"-m\" in call_args\n        assert \"pip\" in call_args\n        assert \"install\" in call_args\n        assert \"-r\" in call_args\n        # Verify the requirements file path is included\n        assert any(constants.MANAGER_REQUIREMENTS_FILE in str(arg) for arg in call_args)\n\n    @patch(\"comfy_cli.command.custom_nodes.command.subprocess.run\")\n    @patch(\"comfy_cli.command.custom_nodes.command.workspace_manager\")\n    def test_migrate_legacy_no_requirements_file(self, mock_ws, mock_subprocess_run, mock_config_manager, tmp_path):\n        # Setup: create legacy manager with .git but NO manager_requirements.txt\n        custom_nodes = tmp_path / \"custom_nodes\"\n        legacy_manager = custom_nodes / \"ComfyUI-Manager\"\n        legacy_manager.mkdir(parents=True)\n        (legacy_manager / \".git\").mkdir()\n\n        mock_ws.workspace_path = str(tmp_path)\n\n        from comfy_cli.command.custom_nodes.command import migrate_legacy\n\n        migrate_legacy(yes=True)\n\n        # Verify pip install was NOT called (no requirements file)\n        mock_subprocess_run.assert_not_called()\n        # When requirements file is missing, install fails → set to disable\n        mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, \"disable\")\n\n    @patch(\"comfy_cli.command.custom_nodes.command.workspace_manager\")\n    def test_migrate_legacy_not_git_repo(self, mock_ws, mock_config_manager, tmp_path):\n        # Setup: create directory without .git (not a git repo)\n        custom_nodes = tmp_path / \"custom_nodes\"\n        legacy_manager = custom_nodes / \"ComfyUI-Manager\"\n        legacy_manager.mkdir(parents=True)\n        # No .git directory\n\n        mock_ws.workspace_path = str(tmp_path)\n\n        from comfy_cli.command.custom_nodes.command import migrate_legacy\n\n        migrate_legacy(yes=True)\n\n        # Should not migrate non-git directories\n        mock_config_manager.set.assert_not_called()\n        # Directory should still exist (not moved)\n        assert legacy_manager.exists()\n\n    @patch(\"comfy_cli.command.custom_nodes.command.workspace_manager\")\n    def test_migrate_legacy_skips_symlink(self, mock_ws, mock_config_manager, tmp_path):\n        # Setup: create a symlink instead of real directory\n        custom_nodes = tmp_path / \"custom_nodes\"\n        custom_nodes.mkdir(parents=True)\n        real_dir = tmp_path / \"real-manager\"\n        real_dir.mkdir()\n        (real_dir / \".git\").mkdir()\n        symlink_path = custom_nodes / \"ComfyUI-Manager\"\n        symlink_path.symlink_to(real_dir)\n\n        mock_ws.workspace_path = str(tmp_path)\n\n        from comfy_cli.command.custom_nodes.command import migrate_legacy\n\n        migrate_legacy(yes=True)\n\n        # Should not migrate symlinks\n        mock_config_manager.set.assert_not_called()\n        # Symlink should still exist\n        assert symlink_path.is_symlink()\n\n    @patch(\"comfy_cli.command.custom_nodes.command.shutil.move\")\n    @patch(\"comfy_cli.command.custom_nodes.command.workspace_manager\")\n    def test_migrate_legacy_move_error(self, mock_ws, mock_move, mock_config_manager, tmp_path):\n        # Setup: create legacy manager with .git\n        custom_nodes = tmp_path / \"custom_nodes\"\n        legacy_manager = custom_nodes / \"ComfyUI-Manager\"\n        legacy_manager.mkdir(parents=True)\n        (legacy_manager / \".git\").mkdir()\n        (custom_nodes / \".disabled\").mkdir()\n\n        mock_ws.workspace_path = str(tmp_path)\n        mock_move.side_effect = OSError(\"Permission denied\")\n\n        from comfy_cli.command.custom_nodes.command import migrate_legacy\n\n        with pytest.raises(typer.Exit):\n            migrate_legacy(yes=True)\n\n        # Config should not be set on move failure\n        mock_config_manager.set.assert_not_called()\n\n    @patch(\"comfy_cli.command.custom_nodes.command.ui.prompt_confirm_action\")\n    @patch(\"comfy_cli.command.custom_nodes.command.workspace_manager\")\n    def test_migrate_legacy_user_cancels(self, mock_ws, mock_confirm, mock_config_manager, tmp_path):\n        # Setup: create legacy manager with .git\n        custom_nodes = tmp_path / \"custom_nodes\"\n        legacy_manager = custom_nodes / \"ComfyUI-Manager\"\n        legacy_manager.mkdir(parents=True)\n        (legacy_manager / \".git\").mkdir()\n\n        mock_ws.workspace_path = str(tmp_path)\n        mock_confirm.return_value = False  # User cancels\n\n        from comfy_cli.command.custom_nodes.command import migrate_legacy\n\n        migrate_legacy(yes=False)\n\n        # Should not migrate when user cancels\n        mock_config_manager.set.assert_not_called()\n        # Directory should still exist\n        assert legacy_manager.exists()\n\n\nclass TestInstallSkipManager:\n    \"\"\"Tests for --skip-manager flag setting config to disable.\"\"\"\n\n    @patch(\"comfy_cli.command.install.update_node_id_cache\")\n    @patch(\"comfy_cli.command.install.pip_install_manager\")\n    @patch(\"comfy_cli.command.install.pip_install_comfyui_dependencies\")\n    @patch(\"comfy_cli.command.install.workspace_manager\")\n    @patch(\"comfy_cli.command.install.WorkspaceManager\")\n    @patch(\"comfy_cli.command.install.check_comfy_repo\")\n    @patch(\"comfy_cli.command.install.clone_comfyui\")\n    @patch(\"comfy_cli.command.install.ui.prompt_confirm_action\")\n    @patch(\"comfy_cli.config_manager.ConfigManager\")\n    @patch(\"os.path.exists\")\n    @patch(\"os.makedirs\")\n    @patch(\"os.chdir\")\n    @patch(\"comfy_cli.command.install.ensure_workspace_python\", return_value=\"/fake/python\")\n    def test_skip_manager_sets_disable_config(\n        self,\n        mock_ensure_python,\n        mock_chdir,\n        mock_makedirs,\n        mock_exists,\n        mock_config_manager_cls,\n        mock_confirm,\n        mock_clone,\n        mock_check_repo,\n        mock_ws_cls,\n        mock_ws,\n        mock_pip_deps,\n        mock_pip_manager,\n        mock_update_cache,\n    ):\n        \"\"\"When --skip-manager is used, config should be set to disable.\"\"\"\n        # Setup mocks\n        mock_exists.side_effect = lambda p: p == \"/fake/comfy\"  # repo exists\n        mock_check_repo.return_value = (True, None)\n        mock_ws.skip_prompting = True\n        mock_config_manager = MagicMock()\n        mock_config_manager_cls.return_value = mock_config_manager\n\n        from comfy_cli.command.install import execute\n\n        execute(\n            url=\"https://github.com/comfyanonymous/ComfyUI\",\n            comfy_path=\"/fake/comfy\",\n            restore=False,\n            skip_manager=True,  # Key flag\n            version=\"nightly\",\n        )\n\n        # Verify config was set to disable\n        mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, \"disable\")\n        # Verify pip_install_manager was NOT called\n        mock_pip_manager.assert_not_called()\n\n\nclass TestInstallManagerFailure:\n    \"\"\"Tests for pip_install_manager failure handling.\"\"\"\n\n    @patch(\"comfy_cli.command.install.update_node_id_cache\")\n    @patch(\"comfy_cli.command.install.pip_install_manager\")\n    @patch(\"comfy_cli.command.install.pip_install_comfyui_dependencies\")\n    @patch(\"comfy_cli.command.install.workspace_manager\")\n    @patch(\"comfy_cli.command.install.WorkspaceManager\")\n    @patch(\"comfy_cli.command.install.check_comfy_repo\")\n    @patch(\"comfy_cli.command.install.clone_comfyui\")\n    @patch(\"comfy_cli.command.install.ui.prompt_confirm_action\")\n    @patch(\"comfy_cli.config_manager.ConfigManager\")\n    @patch(\"os.path.exists\")\n    @patch(\"os.makedirs\")\n    @patch(\"os.chdir\")\n    @patch(\"comfy_cli.command.install.ensure_workspace_python\", return_value=\"/fake/python\")\n    def test_manager_install_failure_sets_disable_config(\n        self,\n        mock_ensure_python,\n        mock_chdir,\n        mock_makedirs,\n        mock_exists,\n        mock_config_manager_cls,\n        mock_confirm,\n        mock_clone,\n        mock_check_repo,\n        mock_ws_cls,\n        mock_ws,\n        mock_pip_deps,\n        mock_pip_manager,\n        mock_update_cache,\n    ):\n        \"\"\"When pip_install_manager fails, config should be set to disable.\"\"\"\n        # Setup mocks\n        mock_exists.side_effect = lambda p: p == \"/fake/comfy\"  # repo exists\n        mock_check_repo.return_value = (True, None)\n        mock_ws.skip_prompting = True\n        mock_pip_manager.return_value = False  # Manager installation fails\n        mock_config_manager = MagicMock()\n        mock_config_manager_cls.return_value = mock_config_manager\n\n        from comfy_cli.command.install import execute\n\n        execute(\n            url=\"https://github.com/comfyanonymous/ComfyUI\",\n            comfy_path=\"/fake/comfy\",\n            restore=False,\n            skip_manager=False,  # Try to install manager\n            version=\"nightly\",\n        )\n\n        # Verify pip_install_manager was called\n        mock_pip_manager.assert_called_once()\n        # Verify config was set to disable due to failure\n        mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, \"disable\")\n\n    @patch(\"comfy_cli.command.install.update_node_id_cache\")\n    @patch(\"comfy_cli.command.install.pip_install_manager\")\n    @patch(\"comfy_cli.command.install.pip_install_comfyui_dependencies\")\n    @patch(\"comfy_cli.command.install.workspace_manager\")\n    @patch(\"comfy_cli.command.install.WorkspaceManager\")\n    @patch(\"comfy_cli.command.install.check_comfy_repo\")\n    @patch(\"comfy_cli.command.install.clone_comfyui\")\n    @patch(\"comfy_cli.command.install.ui.prompt_confirm_action\")\n    @patch(\"comfy_cli.config_manager.ConfigManager\")\n    @patch(\"os.path.exists\")\n    @patch(\"os.makedirs\")\n    @patch(\"os.chdir\")\n    @patch(\"comfy_cli.command.install.ensure_workspace_python\", return_value=\"/fake/python\")\n    def test_manager_install_success_does_not_set_disable(\n        self,\n        mock_ensure_python,\n        mock_chdir,\n        mock_makedirs,\n        mock_exists,\n        mock_config_manager_cls,\n        mock_confirm,\n        mock_clone,\n        mock_check_repo,\n        mock_ws_cls,\n        mock_ws,\n        mock_pip_deps,\n        mock_pip_manager,\n        mock_update_cache,\n    ):\n        \"\"\"When pip_install_manager succeeds, config should NOT be set to disable.\"\"\"\n        # Setup mocks\n        mock_exists.side_effect = lambda p: p == \"/fake/comfy\"  # repo exists\n        mock_check_repo.return_value = (True, None)\n        mock_ws.skip_prompting = True\n        mock_pip_manager.return_value = True  # Manager installation succeeds\n        mock_config_manager = MagicMock()\n        mock_config_manager_cls.return_value = mock_config_manager\n\n        from comfy_cli.command.install import execute\n\n        execute(\n            url=\"https://github.com/comfyanonymous/ComfyUI\",\n            comfy_path=\"/fake/comfy\",\n            restore=False,\n            skip_manager=False,\n            version=\"nightly\",\n        )\n\n        # Verify pip_install_manager was called\n        mock_pip_manager.assert_called_once()\n        # Verify config was NOT set to disable\n        mock_config_manager.set.assert_not_called()\n\n    @patch(\"comfy_cli.command.install.DependencyCompiler\")\n    @patch(\"comfy_cli.command.install.update_node_id_cache\")\n    @patch(\"comfy_cli.command.install.pip_install_manager\")\n    @patch(\"comfy_cli.command.install.pip_install_comfyui_dependencies\")\n    @patch(\"comfy_cli.command.install.workspace_manager\")\n    @patch(\"comfy_cli.command.install.WorkspaceManager\")\n    @patch(\"comfy_cli.command.install.check_comfy_repo\")\n    @patch(\"comfy_cli.command.install.clone_comfyui\")\n    @patch(\"comfy_cli.command.install.ui.prompt_confirm_action\")\n    @patch(\"comfy_cli.config_manager.ConfigManager\")\n    @patch(\"os.path.exists\")\n    @patch(\"os.makedirs\")\n    @patch(\"os.chdir\")\n    @patch(\"comfy_cli.command.install.ensure_workspace_python\", return_value=\"/fake/python\")\n    def test_fast_deps_manager_failure_sets_disable_config(\n        self,\n        mock_ensure_python,\n        mock_chdir,\n        mock_makedirs,\n        mock_exists,\n        mock_config_manager_cls,\n        mock_confirm,\n        mock_clone,\n        mock_check_repo,\n        mock_ws_cls,\n        mock_ws,\n        mock_pip_deps,\n        mock_pip_manager,\n        mock_update_cache,\n        mock_dep_compiler,\n    ):\n        \"\"\"When fast_deps=True and pip_install_manager fails, config should be set to disable.\"\"\"\n        # Setup mocks\n        mock_exists.side_effect = lambda p: p == \"/fake/comfy\"\n        mock_check_repo.return_value = (True, None)\n        mock_ws.skip_prompting = True\n        mock_pip_manager.return_value = False  # Manager installation fails\n        mock_config_manager = MagicMock()\n        mock_config_manager_cls.return_value = mock_config_manager\n        mock_dep_compiler_instance = MagicMock()\n        mock_dep_compiler.return_value = mock_dep_compiler_instance\n\n        from comfy_cli.command.install import execute\n\n        execute(\n            url=\"https://github.com/comfyanonymous/ComfyUI\",\n            comfy_path=\"/fake/comfy\",\n            restore=False,\n            skip_manager=False,\n            version=\"nightly\",\n            fast_deps=True,  # Use fast_deps path\n        )\n\n        # Verify pip_install_manager was called (fast_deps path)\n        mock_pip_manager.assert_called_once()\n        # Verify config was set to disable due to failure\n        mock_config_manager.set.assert_called_with(constants.CONFIG_KEY_MANAGER_GUI_MODE, \"disable\")\n\n\nclass TestPipInstallManagerCacheClear:\n    \"\"\"Tests for pip_install_manager cache clearing after successful install.\"\"\"\n\n    @patch(\"comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli\")\n    @patch(\"comfy_cli.command.install.subprocess.run\")\n    @patch(\"os.path.exists\", return_value=True)\n    def test_pip_install_manager_clears_cache_on_success(self, mock_exists, mock_run, mock_find_cm_cli):\n        \"\"\"When pip install succeeds, find_cm_cli cache should be cleared.\"\"\"\n        from comfy_cli.command.install import pip_install_manager\n\n        # Simulate successful pip install\n        mock_run.return_value = MagicMock(returncode=0, stderr=\"\")\n\n        # Call pip_install_manager\n        result = pip_install_manager(\"/fake/repo\")\n\n        # Verify success\n        assert result is True\n        # Verify cache_clear was called on the mock\n        mock_find_cm_cli.cache_clear.assert_called_once()\n\n    @patch(\"comfy_cli.command.install.subprocess.run\")\n    @patch(\"os.path.exists\", return_value=True)\n    def test_pip_install_manager_no_cache_clear_on_failure(self, mock_exists, mock_run):\n        \"\"\"When pip install fails, cache should not be affected (function returns early).\"\"\"\n        from comfy_cli.command.install import pip_install_manager\n\n        # Simulate failed pip install\n        mock_run.return_value = MagicMock(returncode=1)\n\n        # Call pip_install_manager\n        result = pip_install_manager(\"/fake/repo\")\n\n        # Verify failure\n        assert result is False\n\n\nclass TestFillPrintTable:\n    \"\"\"Tests for WorkspaceManager.fill_print_table() method.\"\"\"\n\n    @pytest.fixture()\n    def mock_workspace_config_manager(self):\n        with patch(\"comfy_cli.workspace_manager.ConfigManager\") as mock_cls:\n            instance = MagicMock()\n            mock_cls.return_value = instance\n            yield instance\n\n    @patch(\"comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode\", return_value=\"disable\")\n    def test_fill_print_table_disable_mode(self, mock_resolve, mock_workspace_config_manager):\n        \"\"\"When mode is 'disable', status should show Disabled.\"\"\"\n        from comfy_cli.workspace_manager import WorkspaceManager\n\n        ws = WorkspaceManager()\n        ws.workspace_path = \"/fake/workspace\"\n\n        result = ws.fill_print_table()\n\n        assert len(result) == 3\n        assert result[0][0] == \"Current selected workspace\"\n        assert result[1][0] == \"Manager\"\n        assert \"Disabled\" in result[1][1]\n        assert result[2][0] == \"UV Compile Default\"\n\n    @patch(\"comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode\", return_value=\"enable-gui\")\n    def test_fill_print_table_enable_gui_mode(self, mock_resolve, mock_workspace_config_manager):\n        \"\"\"When mode is 'enable-gui', status should show GUI Enabled.\"\"\"\n        from comfy_cli.workspace_manager import WorkspaceManager\n\n        ws = WorkspaceManager()\n        ws.workspace_path = \"/fake/workspace\"\n\n        result = ws.fill_print_table()\n\n        assert result[1][0] == \"Manager\"\n        assert \"GUI Enabled\" in result[1][1]\n\n    @patch(\"comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode\", return_value=\"disable-gui\")\n    def test_fill_print_table_disable_gui_mode(self, mock_resolve, mock_workspace_config_manager):\n        \"\"\"When mode is 'disable-gui', status should show GUI Disabled.\"\"\"\n        from comfy_cli.workspace_manager import WorkspaceManager\n\n        ws = WorkspaceManager()\n        ws.workspace_path = \"/fake/workspace\"\n\n        result = ws.fill_print_table()\n\n        assert result[1][0] == \"Manager\"\n        assert \"GUI Disabled\" in result[1][1]\n\n    @patch(\"comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode\", return_value=\"enable-legacy-gui\")\n    def test_fill_print_table_enable_legacy_gui_mode(self, mock_resolve, mock_workspace_config_manager):\n        \"\"\"When mode is 'enable-legacy-gui', status should show Legacy GUI.\"\"\"\n        from comfy_cli.workspace_manager import WorkspaceManager\n\n        ws = WorkspaceManager()\n        ws.workspace_path = \"/fake/workspace\"\n\n        result = ws.fill_print_table()\n\n        assert result[1][0] == \"Manager\"\n        assert \"Legacy GUI\" in result[1][1]\n\n    @patch(\"comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode\", return_value=\"not-installed\")\n    def test_fill_print_table_not_installed(self, mock_resolve, mock_workspace_config_manager):\n        \"\"\"When resolve returns 'not-installed', status should show Not Installed.\"\"\"\n        from comfy_cli.workspace_manager import WorkspaceManager\n\n        ws = WorkspaceManager()\n        ws.workspace_path = \"/fake/workspace\"\n\n        result = ws.fill_print_table()\n\n        assert result[1][0] == \"Manager\"\n        assert \"Not Installed\" in result[1][1]\n\n    @patch(\"comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode\", return_value=\"unknown-mode\")\n    def test_fill_print_table_unknown_mode_defaults_to_enable(self, mock_resolve, mock_workspace_config_manager):\n        \"\"\"When mode is unknown, status should default to GUI Enabled.\"\"\"\n        from comfy_cli.workspace_manager import WorkspaceManager\n\n        ws = WorkspaceManager()\n        ws.workspace_path = \"/fake/workspace\"\n\n        result = ws.fill_print_table()\n\n        assert result[1][0] == \"Manager\"\n        assert \"GUI Enabled\" in result[1][1]\n\n    @patch(\"comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode\", return_value=\"enable-gui\")\n    def test_fill_print_table_uv_compile_enabled(self, mock_resolve, mock_workspace_config_manager):\n        \"\"\"When uv_compile_default is True, status should show Enabled.\"\"\"\n        mock_workspace_config_manager.get.side_effect = lambda key: {\n            constants.CONFIG_KEY_UV_COMPILE_DEFAULT: \"True\",\n        }.get(key)\n\n        from comfy_cli.workspace_manager import WorkspaceManager\n\n        ws = WorkspaceManager()\n        ws.workspace_path = \"/fake/workspace\"\n\n        result = ws.fill_print_table()\n\n        assert result[2][0] == \"UV Compile Default\"\n        assert \"Enabled\" in result[2][1]\n\n    @patch(\"comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode\", return_value=\"enable-gui\")\n    def test_fill_print_table_uv_compile_disabled(self, mock_resolve, mock_workspace_config_manager):\n        \"\"\"When uv_compile_default is not set, status should show Disabled.\"\"\"\n        mock_workspace_config_manager.get.return_value = None\n\n        from comfy_cli.workspace_manager import WorkspaceManager\n\n        ws = WorkspaceManager()\n        ws.workspace_path = \"/fake/workspace\"\n\n        result = ws.fill_print_table()\n\n        assert result[2][0] == \"UV Compile Default\"\n        assert \"Disabled\" in result[2][1]\n\n    @patch(\"comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode\", return_value=\"enable-gui\")\n    def test_fill_print_table_uv_compile_lowercase_true(self, mock_resolve, mock_workspace_config_manager):\n        \"\"\"When uv_compile_default is 'true' (lowercase), status should show Enabled.\"\"\"\n        mock_workspace_config_manager.get.return_value = \"true\"\n\n        from comfy_cli.workspace_manager import WorkspaceManager\n\n        ws = WorkspaceManager()\n        ws.workspace_path = \"/fake/workspace\"\n\n        result = ws.fill_print_table()\n\n        assert result[2][0] == \"UV Compile Default\"\n        assert \"Enabled\" in result[2][1]\n\n    @patch(\"comfy_cli.command.custom_nodes.cm_cli_util.resolve_manager_gui_mode\", return_value=\"enable-gui\")\n    def test_fill_print_table_uv_compile_explicit_false(self, mock_resolve, mock_workspace_config_manager):\n        \"\"\"When uv_compile_default is 'False', status should show Disabled.\"\"\"\n        mock_workspace_config_manager.get.return_value = \"False\"\n\n        from comfy_cli.workspace_manager import WorkspaceManager\n\n        ws = WorkspaceManager()\n        ws.workspace_path = \"/fake/workspace\"\n\n        result = ws.fill_print_table()\n\n        assert result[2][0] == \"UV Compile Default\"\n        assert \"Disabled\" in result[2][1]\n\n\nclass TestResolveUvCompile:\n    \"\"\"Tests for _resolve_uv_compile() helper function.\"\"\"\n\n    @pytest.fixture()\n    def mock_resolve_config_manager(self):\n        with patch(\"comfy_cli.command.custom_nodes.command.ConfigManager\") as mock_cls:\n            instance = MagicMock()\n            mock_cls.return_value = instance\n            yield instance\n\n    def test_explicit_true_returns_true(self, mock_resolve_config_manager):\n        \"\"\"Explicit --uv-compile overrides everything.\"\"\"\n        from comfy_cli.command.custom_nodes.command import _resolve_uv_compile\n\n        assert _resolve_uv_compile(True) is True\n\n    def test_explicit_false_returns_false(self, mock_resolve_config_manager):\n        \"\"\"Explicit --no-uv-compile overrides everything.\"\"\"\n        from comfy_cli.command.custom_nodes.command import _resolve_uv_compile\n\n        assert _resolve_uv_compile(False) is False\n\n    def test_explicit_true_ignores_config(self, mock_resolve_config_manager):\n        \"\"\"Explicit flag takes priority over config default.\"\"\"\n        mock_resolve_config_manager.get.return_value = \"False\"\n        from comfy_cli.command.custom_nodes.command import _resolve_uv_compile\n\n        assert _resolve_uv_compile(True) is True\n        mock_resolve_config_manager.get.assert_not_called()\n\n    def test_none_with_config_true(self, mock_resolve_config_manager):\n        \"\"\"None (no flag) + config True → True.\"\"\"\n        mock_resolve_config_manager.get.return_value = \"True\"\n        from comfy_cli.command.custom_nodes.command import _resolve_uv_compile\n\n        assert _resolve_uv_compile(None) is True\n\n    def test_none_with_config_false(self, mock_resolve_config_manager):\n        \"\"\"None (no flag) + config False → False.\"\"\"\n        mock_resolve_config_manager.get.return_value = \"False\"\n        from comfy_cli.command.custom_nodes.command import _resolve_uv_compile\n\n        assert _resolve_uv_compile(None) is False\n\n    def test_none_with_no_config(self, mock_resolve_config_manager):\n        \"\"\"None (no flag) + no config → False.\"\"\"\n        mock_resolve_config_manager.get.return_value = None\n        from comfy_cli.command.custom_nodes.command import _resolve_uv_compile\n\n        assert _resolve_uv_compile(None) is False\n\n    def test_config_true_overridden_by_fast_deps(self, mock_resolve_config_manager):\n        \"\"\"Config True + --fast-deps → False (silent override).\"\"\"\n        mock_resolve_config_manager.get.return_value = \"True\"\n        from comfy_cli.command.custom_nodes.command import _resolve_uv_compile\n\n        assert _resolve_uv_compile(None, fast_deps=True) is False\n\n    def test_config_true_overridden_by_no_deps(self, mock_resolve_config_manager):\n        \"\"\"Config True + --no-deps → False (silent override).\"\"\"\n        mock_resolve_config_manager.get.return_value = \"True\"\n        from comfy_cli.command.custom_nodes.command import _resolve_uv_compile\n\n        assert _resolve_uv_compile(None, no_deps=True) is False\n\n    def test_config_false_with_fast_deps_stays_false(self, mock_resolve_config_manager):\n        \"\"\"Config False + --fast-deps → False (no change).\"\"\"\n        mock_resolve_config_manager.get.return_value = \"False\"\n        from comfy_cli.command.custom_nodes.command import _resolve_uv_compile\n\n        assert _resolve_uv_compile(None, fast_deps=True) is False\n\n    def test_explicit_true_not_affected_by_fast_deps(self, mock_resolve_config_manager):\n        \"\"\"Explicit --uv-compile is not silently overridden (mutual exclusivity handled elsewhere).\"\"\"\n        from comfy_cli.command.custom_nodes.command import _resolve_uv_compile\n\n        assert _resolve_uv_compile(True, fast_deps=True) is True\n\n\nclass TestUvCompileDefaultCommand:\n    \"\"\"Tests for comfy manager uv-compile-default command.\"\"\"\n\n    def test_uv_compile_default_enable(self, mock_config_manager):\n        from comfy_cli.command.custom_nodes.command import uv_compile_default\n\n        uv_compile_default(enabled=True)\n\n        mock_config_manager.set.assert_called_once_with(constants.CONFIG_KEY_UV_COMPILE_DEFAULT, \"True\")\n\n    def test_uv_compile_default_disable(self, mock_config_manager):\n        from comfy_cli.command.custom_nodes.command import uv_compile_default\n\n        uv_compile_default(enabled=False)\n\n        mock_config_manager.set.assert_called_once_with(constants.CONFIG_KEY_UV_COMPILE_DEFAULT, \"False\")\n\n\nclass TestFindCmCli:\n    \"\"\"Tests for find_cm_cli() function.\"\"\"\n\n    def test_find_cm_cli_module_found(self):\n        \"\"\"When cm_cli module exists, should return True.\"\"\"\n        with patch(\"importlib.util.find_spec\") as mock_find_spec:\n            mock_find_spec.return_value = MagicMock()  # Non-None means module exists\n            # Clear cache before test\n            from comfy_cli.command.custom_nodes.cm_cli_util import find_cm_cli\n\n            find_cm_cli.cache_clear()\n\n            result = find_cm_cli()\n\n            assert result is True\n            mock_find_spec.assert_called_with(\"cm_cli\")\n\n    def test_find_cm_cli_module_not_found(self):\n        \"\"\"When cm_cli module doesn't exist, should return False.\"\"\"\n        from comfy_cli.command.custom_nodes import cm_cli_util as _cm_cli_util\n\n        with (\n            patch(\"importlib.util.find_spec\") as mock_find_spec,\n            patch.object(_cm_cli_util.workspace_manager, \"workspace_path\", None),\n        ):\n            mock_find_spec.return_value = None  # None means module not found\n            from comfy_cli.command.custom_nodes.cm_cli_util import find_cm_cli\n\n            find_cm_cli.cache_clear()\n\n            result = find_cm_cli()\n\n            assert result is False\n            mock_find_spec.assert_called_with(\"cm_cli\")\n\n    def test_find_cm_cli_cache_behavior(self):\n        \"\"\"find_cm_cli should cache results and not call find_spec repeatedly.\"\"\"\n        with patch(\"importlib.util.find_spec\") as mock_find_spec:\n            mock_find_spec.return_value = MagicMock()\n            from comfy_cli.command.custom_nodes.cm_cli_util import find_cm_cli\n\n            find_cm_cli.cache_clear()\n\n            # Call multiple times\n            result1 = find_cm_cli()\n            result2 = find_cm_cli()\n            result3 = find_cm_cli()\n\n            # All should return True\n            assert result1 is True\n            assert result2 is True\n            assert result3 is True\n            # find_spec should only be called once due to caching\n            assert mock_find_spec.call_count == 1\n\n\nclass TestPipInstallManagerEdgeCases:\n    \"\"\"Additional edge case tests for pip_install_manager().\"\"\"\n\n    @patch(\"comfy_cli.command.install.subprocess.run\")\n    @patch(\"os.path.exists\", return_value=False)\n    def test_pip_install_manager_requirements_not_found(self, mock_exists, mock_run):\n        \"\"\"When requirements file doesn't exist, should return False without calling pip.\"\"\"\n        from comfy_cli.command.install import pip_install_manager\n\n        result = pip_install_manager(\"/fake/repo\")\n\n        assert result is False\n        # subprocess.run should NOT be called\n        mock_run.assert_not_called()\n\n\nclass TestValidateComfyuiManager:\n    \"\"\"Tests for validate_comfyui_manager() function.\"\"\"\n\n    @patch(\"comfy_cli.command.custom_nodes.command.find_cm_cli\", return_value=False)\n    def test_validate_comfyui_manager_exits_when_not_found(self, mock_find_cm_cli):\n        \"\"\"When cm-cli is not found, should raise typer.Exit with code 1.\"\"\"\n        from comfy_cli.command.custom_nodes.command import validate_comfyui_manager\n\n        with pytest.raises(typer.Exit) as exc_info:\n            validate_comfyui_manager()\n\n        assert exc_info.value.exit_code == 1\n        mock_find_cm_cli.assert_called_once()\n\n    @patch(\"comfy_cli.command.custom_nodes.command.find_cm_cli\", return_value=True)\n    def test_validate_comfyui_manager_passes_when_found(self, mock_find_cm_cli):\n        \"\"\"When cm-cli is found, should not raise any exception.\"\"\"\n        from comfy_cli.command.custom_nodes.command import validate_comfyui_manager\n\n        # Should not raise\n        validate_comfyui_manager()\n\n        mock_find_cm_cli.assert_called_once()\n"
  },
  {
    "path": "tests/comfy_cli/command/test_npm_help.py",
    "content": "\"\"\"Tests for install command functionality\"\"\"\n\nfrom io import StringIO\nfrom unittest.mock import patch\n\nimport pytest\n\nfrom comfy_cli.command.install import _print_npm_not_found_help\n\n\nclass TestPrintNpmNotFoundHelp:\n    \"\"\"Tests for _print_npm_not_found_help function\"\"\"\n\n    @pytest.fixture\n    def capture_output(self):\n        \"\"\"Fixture to capture rich console output\"\"\"\n        output = StringIO()\n        with patch(\n            \"comfy_cli.command.install.rprint\",\n            side_effect=lambda *args: output.write(str(args[0]) + \"\\n\" if args else \"\\n\"),\n        ):\n            yield output\n\n    def test_npm_not_found_help_shows_common_message(self, capture_output):\n        \"\"\"Test that common npm not found message is shown regardless of OS\"\"\"\n        with patch(\"platform.system\", return_value=\"Linux\"):\n            _print_npm_not_found_help(\"v20.0.0\")\n\n        output_text = capture_output.getvalue()\n        assert \"npm is not installed or not found in PATH\" in output_text\n        assert \"npm is a package manager that usually comes bundled with Node.js\" in output_text\n        assert \"v20.0.0\" in output_text\n        assert \"After fixing npm, run your comfy command again\" in output_text\n\n    def test_npm_not_found_help_windows(self, capture_output):\n        \"\"\"Test Windows-specific instructions\"\"\"\n        with patch(\"platform.system\", return_value=\"Windows\"):\n            _print_npm_not_found_help(\"v18.17.0\")\n\n        output_text = capture_output.getvalue()\n        assert \"How to fix this on Windows\" in output_text\n        assert \"Add or remove programs\" in output_text\n        assert \"Command Prompt or PowerShell\" in output_text\n\n    def test_npm_not_found_help_macos(self, capture_output):\n        \"\"\"Test macOS-specific instructions\"\"\"\n        with patch(\"platform.system\", return_value=\"Darwin\"):\n            _print_npm_not_found_help(\"v18.17.0\")\n\n        output_text = capture_output.getvalue()\n        assert \"How to fix this on macOS\" in output_text\n        assert \"Homebrew\" in output_text\n        assert \"brew install node\" in output_text\n        assert \".pkg file\" in output_text\n        assert \"Cmd+Q\" in output_text\n\n    def test_npm_not_found_help_linux(self, capture_output):\n        \"\"\"Test Linux-specific instructions\"\"\"\n        with patch(\"platform.system\", return_value=\"Linux\"):\n            _print_npm_not_found_help(\"v18.17.0\")\n\n        output_text = capture_output.getvalue()\n        assert \"How to fix this on Linux\" in output_text\n        assert \"sudo apt\" in output_text\n        assert \"Ubuntu/Debian\" in output_text\n        assert \"Fedora\" in output_text\n        assert \"NodeSource\" in output_text\n\n    def test_npm_not_found_help_unknown_os_falls_back_to_linux(self, capture_output):\n        \"\"\"Test that unknown OS falls back to Linux instructions\"\"\"\n        with patch(\"platform.system\", return_value=\"FreeBSD\"):\n            _print_npm_not_found_help(\"v18.17.0\")\n\n        output_text = capture_output.getvalue()\n        # Should show Linux instructions as fallback\n        assert \"How to fix this on Linux\" in output_text\n"
  },
  {
    "path": "tests/comfy_cli/command/test_run.py",
    "content": "import io\nimport json\nimport os\nimport tempfile\nimport urllib.error\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\nimport typer\nfrom websocket import WebSocketException, WebSocketTimeoutException\n\nfrom comfy_cli.command.run import (\n    WorkflowExecution,\n    execute,\n    fetch_object_info,\n    is_ui_workflow,\n)\n\n\n@pytest.fixture\ndef workflow():\n    return {\n        \"1\": {\n            \"class_type\": \"EmptyLatentImage\",\n            \"inputs\": {\"width\": 64, \"height\": 64, \"batch_size\": 1},\n            \"_meta\": {\"title\": \"Empty Latent\"},\n        },\n        \"2\": {\n            \"class_type\": \"PreviewAny\",\n            \"inputs\": {\"source\": [\"1\", 0]},\n            \"_meta\": {\"title\": \"Preview\"},\n        },\n    }\n\n\n@pytest.fixture\ndef workflow_file(workflow):\n    with tempfile.NamedTemporaryFile(mode=\"w\", suffix=\".json\", delete=False) as f:\n        json.dump(workflow, f)\n        f.flush()\n        yield f.name\n    os.unlink(f.name)\n\n\n@pytest.fixture\ndef mock_execution(workflow):\n    progress = MagicMock()\n    progress.add_task.return_value = 0\n    return WorkflowExecution(\n        workflow=workflow,\n        host=\"127.0.0.1\",\n        port=8188,\n        verbose=False,\n        progress=progress,\n        local_paths=False,\n        timeout=30,\n    )\n\n\ndef _make_msg(msg_type, prompt_id, **data_fields):\n    return json.dumps({\"type\": msg_type, \"data\": {\"prompt_id\": prompt_id, **data_fields}})\n\n\nclass TestIsUiWorkflow:\n    def test_detects_ui_workflow(self):\n        assert is_ui_workflow({\"nodes\": [{\"id\": 1}], \"links\": []})\n\n    def test_rejects_api_workflow(self):\n        assert not is_ui_workflow({\"1\": {\"class_type\": \"X\", \"inputs\": {}}})\n\n    def test_rejects_non_dict(self):\n        assert not is_ui_workflow([\"nodes\", \"links\"])\n        assert not is_ui_workflow(None)\n\n    def test_requires_both_keys(self):\n        assert not is_ui_workflow({\"nodes\": []})\n        assert not is_ui_workflow({\"links\": []})\n\n    def test_rejects_api_workflow_with_nodes_and_links_as_keys(self):\n        # A pathological API workflow where node IDs happen to be the strings\n        # \"nodes\" and \"links\" should not be mistaken for UI format.\n        api = {\n            \"nodes\": {\"class_type\": \"Foo\", \"inputs\": {}},\n            \"links\": {\"class_type\": \"Bar\", \"inputs\": {}},\n        }\n        assert not is_ui_workflow(api)\n\n    def test_rejects_when_values_are_not_lists(self):\n        assert not is_ui_workflow({\"nodes\": \"string\", \"links\": \"string\"})\n        assert not is_ui_workflow({\"nodes\": 1, \"links\": 2})\n\n\ndef _make_http_error(code: int, body: bytes = b\"\") -> urllib.error.HTTPError:\n    return urllib.error.HTTPError(\n        url=\"http://127.0.0.1:8188/object_info\",\n        code=code,\n        msg=f\"HTTP {code}\",\n        hdrs=None,\n        fp=io.BytesIO(body),\n    )\n\n\ndef _ok_response(body: bytes) -> MagicMock:\n    resp = MagicMock()\n    resp.read.return_value = body\n    resp.__enter__ = MagicMock(return_value=resp)\n    resp.__exit__ = MagicMock(return_value=False)\n    return resp\n\n\nclass TestFetchObjectInfo:\n    def test_returns_parsed_json_on_success(self):\n        payload = {\"KSampler\": {\"input\": {}, \"output_node\": False}}\n        with patch(\n            \"comfy_cli.command.run.request.urlopen\",\n            return_value=_ok_response(json.dumps(payload).encode()),\n        ) as mock_open:\n            result = fetch_object_info(\"127.0.0.1\", 8188, timeout=30)\n        assert result == payload\n        assert mock_open.call_args[0][0] == \"http://127.0.0.1:8188/object_info\"\n\n    def test_http_error_exits_cleanly(self):\n        with patch(\n            \"comfy_cli.command.run.request.urlopen\",\n            side_effect=_make_http_error(500, b\"server exploded\"),\n        ):\n            with pytest.raises(typer.Exit) as exc_info:\n                fetch_object_info(\"127.0.0.1\", 8188, timeout=30)\n            assert exc_info.value.exit_code == 1\n\n    def test_network_error_exits_cleanly(self):\n        with patch(\n            \"comfy_cli.command.run.request.urlopen\",\n            side_effect=urllib.error.URLError(\"Connection refused\"),\n        ):\n            with pytest.raises(typer.Exit) as exc_info:\n                fetch_object_info(\"127.0.0.1\", 8188, timeout=30)\n            assert exc_info.value.exit_code == 1\n\n    def test_timeout_exits_cleanly(self):\n        with patch(\"comfy_cli.command.run.request.urlopen\", side_effect=TimeoutError(\"timed out\")):\n            with pytest.raises(typer.Exit) as exc_info:\n                fetch_object_info(\"127.0.0.1\", 8188, timeout=5)\n            assert exc_info.value.exit_code == 1\n\n    def test_invalid_json_exits_cleanly(self):\n        with patch(\n            \"comfy_cli.command.run.request.urlopen\",\n            return_value=_ok_response(b\"<html>not json</html>\"),\n        ):\n            with pytest.raises(typer.Exit) as exc_info:\n                fetch_object_info(\"127.0.0.1\", 8188, timeout=30)\n            assert exc_info.value.exit_code == 1\n\n\nclass TestWorkflowExecutionAuth:\n    \"\"\"X-API-Key is the credential the ComfyUI server forwards to Partner Nodes.\"\"\"\n\n    def _make_exec(self, workflow, api_key=None):\n        progress = MagicMock()\n        progress.add_task.return_value = 0\n        return WorkflowExecution(\n            workflow=workflow,\n            host=\"127.0.0.1\",\n            port=8188,\n            verbose=False,\n            progress=progress,\n            local_paths=False,\n            timeout=30,\n            api_key=api_key,\n        )\n\n    def test_queue_embeds_api_key_in_extra_data(self, workflow):\n        ex = self._make_exec(workflow, api_key=\"sk-secret\")\n        with patch(\"comfy_cli.command.run.request.urlopen\") as mock_open:\n            mock_open.return_value.read.return_value = json.dumps({\"prompt_id\": \"abc\"}).encode()\n            ex.queue()\n        req = mock_open.call_args[0][0]\n        body = json.loads(req.data)\n        assert body[\"extra_data\"] == {\"api_key_comfy_org\": \"sk-secret\"}\n\n    def test_queue_does_not_send_x_api_key_header(self, workflow):\n        ex = self._make_exec(workflow, api_key=\"sk-secret\")\n        with patch(\"comfy_cli.command.run.request.urlopen\") as mock_open:\n            mock_open.return_value.read.return_value = json.dumps({\"prompt_id\": \"abc\"}).encode()\n            ex.queue()\n        req = mock_open.call_args[0][0]\n        assert req.get_header(\"X-api-key\") is None\n\n    def test_queue_omits_extra_data_when_no_api_key(self, workflow):\n        ex = self._make_exec(workflow)\n        with patch(\"comfy_cli.command.run.request.urlopen\") as mock_open:\n            mock_open.return_value.read.return_value = json.dumps({\"prompt_id\": \"abc\"}).encode()\n            ex.queue()\n        req = mock_open.call_args[0][0]\n        body = json.loads(req.data)\n        assert \"extra_data\" not in body\n        assert body == {\"prompt\": workflow, \"client_id\": ex.client_id}\n\n\nclass TestWatchExecution:\n    def test_successful_execution(self, mock_execution):\n        prompt_id = \"test-prompt\"\n        mock_execution.prompt_id = prompt_id\n\n        messages = [\n            _make_msg(\"executing\", prompt_id, node=\"1\"),\n            _make_msg(\"executed\", prompt_id, node=\"1\"),\n            _make_msg(\"executing\", prompt_id, node=\"2\"),\n            _make_msg(\"executed\", prompt_id, node=\"2\"),\n            _make_msg(\"executing\", prompt_id, node=None),\n        ]\n        mock_ws = MagicMock()\n        mock_ws.recv.side_effect = messages\n        mock_execution.ws = mock_ws\n\n        mock_execution.watch_execution()\n        assert len(mock_execution.remaining_nodes) == 0\n\n    def test_skips_other_prompt_messages(self, mock_execution):\n        prompt_id = \"my-prompt\"\n        mock_execution.prompt_id = prompt_id\n\n        messages = [\n            _make_msg(\"executing\", \"other-prompt\", node=\"1\"),\n            _make_msg(\"executing\", prompt_id, node=None),\n        ]\n        mock_ws = MagicMock()\n        mock_ws.recv.side_effect = messages\n        mock_execution.ws = mock_ws\n\n        mock_execution.watch_execution()\n        assert \"1\" in mock_execution.remaining_nodes\n\n    def test_unknown_node_ids_do_not_crash(self, mock_execution):\n        prompt_id = \"test-prompt\"\n        mock_execution.prompt_id = prompt_id\n\n        messages = [\n            _make_msg(\"executing\", prompt_id, node=\"1\"),\n            _make_msg(\"executing\", prompt_id, node=\"406.0.0.428\"),\n            json.dumps(\n                {\"type\": \"progress\", \"data\": {\"prompt_id\": prompt_id, \"node\": \"406.0.0.428\", \"value\": 5, \"max\": 10}}\n            ),\n            _make_msg(\"executed\", prompt_id, node=\"406.0.0.428\"),\n            json.dumps({\"type\": \"execution_cached\", \"data\": {\"prompt_id\": prompt_id, \"nodes\": [\"999\"]}}),\n            _make_msg(\"executing\", prompt_id, node=None),\n        ]\n        mock_ws = MagicMock()\n        mock_ws.recv.side_effect = messages\n        mock_execution.ws = mock_ws\n\n        mock_execution.watch_execution()\n\n    def test_unknown_node_ids_verbose(self, workflow):\n        prompt_id = \"test-prompt\"\n        progress = MagicMock()\n        progress.add_task.return_value = 0\n        execution = WorkflowExecution(\n            workflow=workflow,\n            host=\"127.0.0.1\",\n            port=8188,\n            verbose=True,\n            progress=progress,\n            local_paths=False,\n            timeout=30,\n        )\n        execution.prompt_id = prompt_id\n\n        messages = [\n            _make_msg(\"executing\", prompt_id, node=\"406.0.0.428\"),\n            json.dumps({\"type\": \"execution_cached\", \"data\": {\"prompt_id\": prompt_id, \"nodes\": [\"999\"]}}),\n            _make_msg(\"executing\", prompt_id, node=None),\n        ]\n        mock_ws = MagicMock()\n        mock_ws.recv.side_effect = messages\n        execution.ws = mock_ws\n\n        execution.watch_execution()\n\n    def test_collects_image_outputs(self, mock_execution):\n        prompt_id = \"test-prompt\"\n        mock_execution.prompt_id = prompt_id\n\n        executed_msg = json.dumps(\n            {\n                \"type\": \"executed\",\n                \"data\": {\n                    \"prompt_id\": prompt_id,\n                    \"node\": \"2\",\n                    \"output\": {\n                        \"images\": [{\"filename\": \"result.png\", \"subfolder\": \"\", \"type\": \"output\"}],\n                    },\n                },\n            }\n        )\n        messages = [\n            _make_msg(\"executing\", prompt_id, node=\"2\"),\n            executed_msg,\n            _make_msg(\"executing\", prompt_id, node=None),\n        ]\n        mock_ws = MagicMock()\n        mock_ws.recv.side_effect = messages\n        mock_execution.ws = mock_ws\n\n        mock_execution.watch_execution()\n        assert len(mock_execution.outputs) == 1\n        assert \"result.png\" in mock_execution.outputs[0]\n\n\nclass TestExecuteErrorHandling:\n    def _run_execute_expect_exit(self, workflow_file, **overrides):\n        kwargs = dict(host=\"127.0.0.1\", port=8188, wait=True, verbose=False, local_paths=False, timeout=30)\n        kwargs.update(overrides)\n        with pytest.raises(typer.Exit) as exc_info:\n            execute(workflow_file, **kwargs)\n        return exc_info.value.exit_code\n\n    def test_timeout_exits_with_code_1(self, workflow_file):\n        with (\n            patch(\"comfy_cli.command.run.check_comfy_server_running\", return_value=True),\n            patch(\"comfy_cli.command.run.ExecutionProgress\"),\n            patch(\"comfy_cli.command.run.WorkflowExecution\") as MockExec,\n        ):\n            mock_exec = MagicMock()\n            MockExec.return_value = mock_exec\n            mock_exec.watch_execution.side_effect = WebSocketTimeoutException(\"timed out\")\n\n            code = self._run_execute_expect_exit(workflow_file)\n            assert code == 1\n\n    def test_connection_error_exits_with_code_1(self, workflow_file):\n        with (\n            patch(\"comfy_cli.command.run.check_comfy_server_running\", return_value=True),\n            patch(\"comfy_cli.command.run.ExecutionProgress\"),\n            patch(\"comfy_cli.command.run.WorkflowExecution\") as MockExec,\n        ):\n            mock_exec = MagicMock()\n            MockExec.return_value = mock_exec\n            mock_exec.connect.side_effect = ConnectionError(\"Connection refused\")\n\n            code = self._run_execute_expect_exit(workflow_file)\n            assert code == 1\n\n    def test_websocket_exception_exits_with_code_1(self, workflow_file):\n        with (\n            patch(\"comfy_cli.command.run.check_comfy_server_running\", return_value=True),\n            patch(\"comfy_cli.command.run.ExecutionProgress\"),\n            patch(\"comfy_cli.command.run.WorkflowExecution\") as MockExec,\n        ):\n            mock_exec = MagicMock()\n            MockExec.return_value = mock_exec\n            mock_exec.watch_execution.side_effect = WebSocketException(\"Connection lost\")\n\n            code = self._run_execute_expect_exit(workflow_file)\n            assert code == 1\n\n    def test_successful_execution(self, workflow_file):\n        with (\n            patch(\"comfy_cli.command.run.check_comfy_server_running\", return_value=True),\n            patch(\"comfy_cli.command.run.ExecutionProgress\") as MockProgress,\n            patch(\"comfy_cli.command.run.WorkflowExecution\") as MockExec,\n        ):\n            mock_progress = MagicMock()\n            MockProgress.return_value = mock_progress\n            mock_exec = MagicMock()\n            MockExec.return_value = mock_exec\n            mock_exec.outputs = []\n\n            execute(workflow_file, host=\"127.0.0.1\", port=8188, wait=True, timeout=30)\n            mock_exec.connect.assert_called_once()\n            mock_exec.queue.assert_called_once()\n            mock_exec.watch_execution.assert_called_once()\n\n    def test_file_not_found_exits(self):\n        with pytest.raises(typer.Exit) as exc_info:\n            execute(\"/nonexistent/workflow.json\", host=\"127.0.0.1\", port=8188)\n        assert exc_info.value.exit_code == 1\n\n    def test_rejects_invalid_workflow_format(self):\n        bad = {\"1\": {\"no_class_type_here\": \"X\"}}\n        with tempfile.NamedTemporaryFile(mode=\"w\", suffix=\".json\", delete=False) as f:\n            json.dump(bad, f)\n            f.flush()\n            path = f.name\n        try:\n            with patch(\"comfy_cli.command.run.check_comfy_server_running\", return_value=True):\n                with pytest.raises(typer.Exit) as exc_info:\n                    execute(path, host=\"127.0.0.1\", port=8188)\n                assert exc_info.value.exit_code == 1\n        finally:\n            os.unlink(path)\n\n    def test_rejects_malformed_json(self):\n        with tempfile.NamedTemporaryFile(mode=\"w\", suffix=\".json\", delete=False) as f:\n            f.write(\"{ this is not valid json\")\n            f.flush()\n            path = f.name\n        try:\n            with patch(\"comfy_cli.command.run.check_comfy_server_running\", return_value=True):\n                with pytest.raises(typer.Exit) as exc_info:\n                    execute(path, host=\"127.0.0.1\", port=8188)\n                assert exc_info.value.exit_code == 1\n        finally:\n            os.unlink(path)\n\n    def test_rejects_unreadable_file(self):\n        with tempfile.NamedTemporaryFile(mode=\"w\", suffix=\".json\", delete=False) as f:\n            f.write(\"{}\")\n            path = f.name\n        try:\n            real_open = open\n\n            def fake_open(file, *args, **kwargs):\n                if file == path:\n                    raise PermissionError(13, \"Permission denied\", path)\n                return real_open(file, *args, **kwargs)\n\n            with (\n                patch(\"comfy_cli.command.run.check_comfy_server_running\", return_value=True),\n                patch(\"builtins.open\", side_effect=fake_open),\n            ):\n                with pytest.raises(typer.Exit) as exc_info:\n                    execute(path, host=\"127.0.0.1\", port=8188)\n                assert exc_info.value.exit_code == 1\n        finally:\n            os.unlink(path)\n\n    def test_progress_stopped_on_error(self, workflow_file):\n        with (\n            patch(\"comfy_cli.command.run.check_comfy_server_running\", return_value=True),\n            patch(\"comfy_cli.command.run.ExecutionProgress\") as MockProgress,\n            patch(\"comfy_cli.command.run.WorkflowExecution\") as MockExec,\n        ):\n            mock_progress = MagicMock()\n            MockProgress.return_value = mock_progress\n            mock_exec = MagicMock()\n            MockExec.return_value = mock_exec\n            mock_exec.watch_execution.side_effect = WebSocketTimeoutException(\"timed out\")\n\n            with pytest.raises(typer.Exit):\n                execute(workflow_file, host=\"127.0.0.1\", port=8188, wait=True, timeout=30)\n            mock_progress.stop.assert_called()\n\n\nclass TestExecuteUiWorkflow:\n    UI = {\n        \"nodes\": [\n            {\n                \"id\": 1,\n                \"type\": \"EmptyLatentImage\",\n                \"inputs\": [],\n                \"outputs\": [{\"name\": \"LATENT\", \"type\": \"LATENT\", \"links\": [10]}],\n                \"widgets_values\": [512, 512, 1],\n                \"mode\": 0,\n            },\n            {\n                \"id\": 2,\n                \"type\": \"PreviewImage\",\n                \"inputs\": [{\"name\": \"images\", \"link\": 10}],\n                \"outputs\": [],\n                \"mode\": 0,\n            },\n        ],\n        \"links\": [[10, 1, 0, 2, 0, \"IMAGE\"]],\n    }\n    OBJECT_INFO = {\n        \"EmptyLatentImage\": {\n            \"input\": {\n                \"required\": {\n                    \"width\": [\"INT\", {\"default\": 512}],\n                    \"height\": [\"INT\", {\"default\": 512}],\n                    \"batch_size\": [\"INT\", {\"default\": 1}],\n                }\n            },\n            \"input_order\": {\"required\": [\"width\", \"height\", \"batch_size\"]},\n            \"output_node\": False,\n            \"display_name\": \"Empty Latent Image\",\n        },\n        \"PreviewImage\": {\n            \"input\": {\"required\": {\"images\": [\"IMAGE\"]}},\n            \"input_order\": {\"required\": [\"images\"]},\n            \"output_node\": True,\n            \"display_name\": \"Preview Image\",\n        },\n    }\n\n    @pytest.fixture\n    def ui_workflow_file(self):\n        with tempfile.NamedTemporaryFile(mode=\"w\", suffix=\".json\", delete=False) as f:\n            json.dump(self.UI, f)\n            f.flush()\n            path = f.name\n        yield path\n        os.unlink(path)\n\n    def test_ui_workflow_is_converted_then_executed(self, ui_workflow_file):\n        with (\n            patch(\"comfy_cli.command.run.check_comfy_server_running\", return_value=True),\n            patch(\"comfy_cli.command.run.fetch_object_info\", return_value=self.OBJECT_INFO) as mock_fetch,\n            patch(\"comfy_cli.command.run.ExecutionProgress\"),\n            patch(\"comfy_cli.command.run.WorkflowExecution\") as MockExec,\n        ):\n            mock_exec = MagicMock()\n            MockExec.return_value = mock_exec\n            mock_exec.outputs = []\n\n            execute(ui_workflow_file, host=\"127.0.0.1\", port=8188, wait=True, timeout=30)\n\n            mock_fetch.assert_called_once_with(\"127.0.0.1\", 8188, 30)\n            api_workflow = MockExec.call_args.args[0]\n            assert set(api_workflow) == {\"1\", \"2\"}\n            assert api_workflow[\"1\"][\"class_type\"] == \"EmptyLatentImage\"\n            assert api_workflow[\"2\"][\"inputs\"][\"images\"] == [\"1\", 0]\n            mock_exec.queue.assert_called_once()\n\n    def test_ui_workflow_exits_when_server_not_running(self, ui_workflow_file):\n        with (\n            patch(\"comfy_cli.command.run.check_comfy_server_running\", return_value=False),\n            patch(\"comfy_cli.command.run.fetch_object_info\") as mock_fetch,\n        ):\n            with pytest.raises(typer.Exit) as exc_info:\n                execute(ui_workflow_file, host=\"127.0.0.1\", port=8188)\n            assert exc_info.value.exit_code == 1\n            mock_fetch.assert_not_called()\n\n    def test_ui_workflow_exits_cleanly_on_unexpected_converter_crash(self, ui_workflow_file):\n        # If the experimental converter crashes with an unexpected error, the\n        # CLI should still exit with code 1 and a friendly message — not let a\n        # Python traceback escape to the user.\n        with (\n            patch(\"comfy_cli.command.run.check_comfy_server_running\", return_value=True),\n            patch(\"comfy_cli.command.run.fetch_object_info\", return_value=self.OBJECT_INFO),\n            patch(\n                \"comfy_cli.command.run.convert_ui_to_api\",\n                side_effect=RuntimeError(\"simulated converter bug\"),\n            ),\n            patch(\"comfy_cli.command.run.WorkflowExecution\") as MockExec,\n        ):\n            with pytest.raises(typer.Exit) as exc_info:\n                execute(ui_workflow_file, host=\"127.0.0.1\", port=8188, wait=True, timeout=30)\n            assert exc_info.value.exit_code == 1\n            MockExec.assert_not_called()\n\n    def test_ui_workflow_plumbs_api_key_through_to_execution(self, ui_workflow_file):\n        with (\n            patch(\"comfy_cli.command.run.check_comfy_server_running\", return_value=True),\n            patch(\"comfy_cli.command.run.fetch_object_info\", return_value=self.OBJECT_INFO) as mock_fetch,\n            patch(\"comfy_cli.command.run.ExecutionProgress\"),\n            patch(\"comfy_cli.command.run.WorkflowExecution\") as MockExec,\n        ):\n            mock_exec = MagicMock()\n            MockExec.return_value = mock_exec\n            mock_exec.outputs = []\n\n            execute(ui_workflow_file, host=\"127.0.0.1\", port=8188, wait=True, timeout=30, api_key=\"sk-test\")\n\n            mock_fetch.assert_called_once_with(\"127.0.0.1\", 8188, 30)\n            assert MockExec.call_args.kwargs[\"api_key\"] == \"sk-test\"\n\n    def test_ui_workflow_exits_when_conversion_yields_nothing(self):\n        # All nodes are UI-only (Note/PrimitiveNode/Reroute/GetNode/SetNode) and\n        # therefore stripped by the converter → execute() should bail before\n        # ever instantiating WorkflowExecution.\n        empty_ui = {\n            \"nodes\": [\n                {\"id\": 1, \"type\": \"Note\", \"inputs\": [], \"outputs\": [], \"widgets_values\": [\"x\"]},\n                {\"id\": 2, \"type\": \"Reroute\", \"inputs\": [{\"link\": None}], \"outputs\": [{\"links\": []}]},\n            ],\n            \"links\": [],\n        }\n        with tempfile.NamedTemporaryFile(mode=\"w\", suffix=\".json\", delete=False) as f:\n            json.dump(empty_ui, f)\n            f.flush()\n            path = f.name\n        try:\n            with (\n                patch(\"comfy_cli.command.run.check_comfy_server_running\", return_value=True),\n                patch(\"comfy_cli.command.run.fetch_object_info\", return_value=self.OBJECT_INFO),\n                patch(\"comfy_cli.command.run.WorkflowExecution\") as MockExec,\n            ):\n                with pytest.raises(typer.Exit) as exc_info:\n                    execute(path, host=\"127.0.0.1\", port=8188, wait=True, timeout=30)\n                assert exc_info.value.exit_code == 1\n                MockExec.assert_not_called()\n        finally:\n            os.unlink(path)\n"
  },
  {
    "path": "tests/comfy_cli/conftest.py",
    "content": "import os\n\nimport pytest\n\n\n@pytest.fixture(autouse=True)\ndef _preserve_cwd():\n    \"\"\"Restore the working directory after every test.\n\n    Several functions in comfy_cli.command.install (execute,\n    pip_install_comfyui_dependencies) call os.chdir() as a side effect.\n    Without this fixture the changed CWD leaks into subsequent tests and\n    can cause hard-to-debug failures.\n    \"\"\"\n    original = os.getcwd()\n    yield\n    os.chdir(original)\n"
  },
  {
    "path": "tests/comfy_cli/fixtures/sd15_expected_api.json",
    "content": "{\n  \"4\": {\n    \"inputs\": {\n      \"ckpt_name\": \"v1-5-pruned-emaonly-fp16.safetensors\"\n    },\n    \"class_type\": \"CheckpointLoaderSimple\",\n    \"_meta\": {\n      \"title\": \"Load Checkpoint\"\n    }\n  },\n  \"3\": {\n    \"inputs\": {\n      \"seed\": 685468484323813,\n      \"steps\": 20,\n      \"cfg\": 8,\n      \"sampler_name\": \"euler\",\n      \"scheduler\": \"normal\",\n      \"denoise\": 1,\n      \"model\": [\n        \"4\",\n        0\n      ],\n      \"positive\": [\n        \"6\",\n        0\n      ],\n      \"negative\": [\n        \"7\",\n        0\n      ],\n      \"latent_image\": [\n        \"5\",\n        0\n      ]\n    },\n    \"class_type\": \"KSampler\",\n    \"_meta\": {\n      \"title\": \"KSampler\"\n    }\n  },\n  \"8\": {\n    \"inputs\": {\n      \"samples\": [\n        \"3\",\n        0\n      ],\n      \"vae\": [\n        \"4\",\n        2\n      ]\n    },\n    \"class_type\": \"VAEDecode\",\n    \"_meta\": {\n      \"title\": \"VAE Decode\"\n    }\n  },\n  \"9\": {\n    \"inputs\": {\n      \"filename_prefix\": \"SD1.5\",\n      \"images\": [\n        \"8\",\n        0\n      ]\n    },\n    \"class_type\": \"SaveImage\",\n    \"_meta\": {\n      \"title\": \"Save Image\"\n    }\n  },\n  \"7\": {\n    \"inputs\": {\n      \"text\": \"text, watermark\",\n      \"clip\": [\n        \"4\",\n        1\n      ]\n    },\n    \"class_type\": \"CLIPTextEncode\",\n    \"_meta\": {\n      \"title\": \"CLIP Text Encode (Prompt)\"\n    }\n  },\n  \"5\": {\n    \"inputs\": {\n      \"width\": 512,\n      \"height\": 512,\n      \"batch_size\": 1\n    },\n    \"class_type\": \"EmptyLatentImage\",\n    \"_meta\": {\n      \"title\": \"Empty Latent Image\"\n    }\n  },\n  \"6\": {\n    \"inputs\": {\n      \"text\": \"beautiful scenery nature glass bottle landscape, purple galaxy bottle,\",\n      \"clip\": [\n        \"4\",\n        1\n      ]\n    },\n    \"class_type\": \"CLIPTextEncode\",\n    \"_meta\": {\n      \"title\": \"CLIP Text Encode (Prompt)\"\n    }\n  }\n}"
  },
  {
    "path": "tests/comfy_cli/fixtures/sd15_object_info.json",
    "content": "{\n  \"CLIPTextEncode\": {\n    \"input\": {\n      \"required\": {\n        \"text\": [\n          \"STRING\",\n          {\n            \"multiline\": true,\n            \"dynamicPrompts\": true,\n            \"tooltip\": \"The text to be encoded.\"\n          }\n        ],\n        \"clip\": [\n          \"CLIP\",\n          {\n            \"tooltip\": \"The CLIP model used for encoding the text.\"\n          }\n        ]\n      }\n    },\n    \"input_order\": {\n      \"required\": [\n        \"text\",\n        \"clip\"\n      ]\n    },\n    \"is_input_list\": false,\n    \"output\": [\n      \"CONDITIONING\"\n    ],\n    \"output_is_list\": [\n      false\n    ],\n    \"output_name\": [\n      \"CONDITIONING\"\n    ],\n    \"name\": \"CLIPTextEncode\",\n    \"display_name\": \"CLIP Text Encode (Prompt)\",\n    \"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.\",\n    \"python_module\": \"nodes\",\n    \"category\": \"conditioning\",\n    \"output_node\": false,\n    \"has_intermediate_output\": false,\n    \"output_tooltips\": [\n      \"A conditioning containing the embedded text used to guide the diffusion model.\"\n    ],\n    \"search_aliases\": [\n      \"text\",\n      \"prompt\",\n      \"text prompt\",\n      \"positive prompt\",\n      \"negative prompt\",\n      \"encode text\",\n      \"text encoder\",\n      \"encode prompt\"\n    ]\n  },\n  \"CheckpointLoaderSimple\": {\n    \"input\": {\n      \"required\": {\n        \"ckpt_name\": [\n          [\n            \"sd_xl_turbo_1.0_fp16.safetensors\",\n            \"v1-5-pruned-emaonly-fp16.safetensors\"\n          ],\n          {\n            \"tooltip\": \"The name of the checkpoint (model) to load.\"\n          }\n        ]\n      }\n    },\n    \"input_order\": {\n      \"required\": [\n        \"ckpt_name\"\n      ]\n    },\n    \"is_input_list\": false,\n    \"output\": [\n      \"MODEL\",\n      \"CLIP\",\n      \"VAE\"\n    ],\n    \"output_is_list\": [\n      false,\n      false,\n      false\n    ],\n    \"output_name\": [\n      \"MODEL\",\n      \"CLIP\",\n      \"VAE\"\n    ],\n    \"name\": \"CheckpointLoaderSimple\",\n    \"display_name\": \"Load Checkpoint\",\n    \"description\": \"Loads a diffusion model checkpoint, diffusion models are used to denoise latents.\",\n    \"python_module\": \"nodes\",\n    \"category\": \"loaders\",\n    \"output_node\": false,\n    \"has_intermediate_output\": false,\n    \"output_tooltips\": [\n      \"The model used for denoising latents.\",\n      \"The CLIP model used for encoding text prompts.\",\n      \"The VAE model used for encoding and decoding images to and from latent space.\"\n    ],\n    \"search_aliases\": [\n      \"load model\",\n      \"checkpoint\",\n      \"model loader\",\n      \"load checkpoint\",\n      \"ckpt\",\n      \"model\"\n    ]\n  },\n  \"EmptyLatentImage\": {\n    \"input\": {\n      \"required\": {\n        \"width\": [\n          \"INT\",\n          {\n            \"default\": 512,\n            \"min\": 16,\n            \"max\": 16384,\n            \"step\": 8,\n            \"tooltip\": \"The width of the latent images in pixels.\"\n          }\n        ],\n        \"height\": [\n          \"INT\",\n          {\n            \"default\": 512,\n            \"min\": 16,\n            \"max\": 16384,\n            \"step\": 8,\n            \"tooltip\": \"The height of the latent images in pixels.\"\n          }\n        ],\n        \"batch_size\": [\n          \"INT\",\n          {\n            \"default\": 1,\n            \"min\": 1,\n            \"max\": 4096,\n            \"tooltip\": \"The number of latent images in the batch.\"\n          }\n        ]\n      }\n    },\n    \"input_order\": {\n      \"required\": [\n        \"width\",\n        \"height\",\n        \"batch_size\"\n      ]\n    },\n    \"is_input_list\": false,\n    \"output\": [\n      \"LATENT\"\n    ],\n    \"output_is_list\": [\n      false\n    ],\n    \"output_name\": [\n      \"LATENT\"\n    ],\n    \"name\": \"EmptyLatentImage\",\n    \"display_name\": \"Empty Latent Image\",\n    \"description\": \"Create a new batch of empty latent images to be denoised via sampling.\",\n    \"python_module\": \"nodes\",\n    \"category\": \"latent\",\n    \"output_node\": false,\n    \"has_intermediate_output\": false,\n    \"output_tooltips\": [\n      \"The empty latent image batch.\"\n    ],\n    \"search_aliases\": [\n      \"empty\",\n      \"empty latent\",\n      \"new latent\",\n      \"create latent\",\n      \"blank latent\",\n      \"blank\"\n    ]\n  },\n  \"KSampler\": {\n    \"input\": {\n      \"required\": {\n        \"model\": [\n          \"MODEL\",\n          {\n            \"tooltip\": \"The model used for denoising the input latent.\"\n          }\n        ],\n        \"seed\": [\n          \"INT\",\n          {\n            \"default\": 0,\n            \"min\": 0,\n            \"max\": 18446744073709551615,\n            \"control_after_generate\": true,\n            \"tooltip\": \"The random seed used for creating the noise.\"\n          }\n        ],\n        \"steps\": [\n          \"INT\",\n          {\n            \"default\": 20,\n            \"min\": 1,\n            \"max\": 10000,\n            \"tooltip\": \"The number of steps used in the denoising process.\"\n          }\n        ],\n        \"cfg\": [\n          \"FLOAT\",\n          {\n            \"default\": 8.0,\n            \"min\": 0.0,\n            \"max\": 100.0,\n            \"step\": 0.1,\n            \"round\": 0.01,\n            \"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.\"\n          }\n        ],\n        \"sampler_name\": [\n          [\n            \"euler\",\n            \"euler_cfg_pp\",\n            \"euler_ancestral\",\n            \"euler_ancestral_cfg_pp\",\n            \"heun\",\n            \"heunpp2\",\n            \"exp_heun_2_x0\",\n            \"exp_heun_2_x0_sde\",\n            \"dpm_2\",\n            \"dpm_2_ancestral\",\n            \"lms\",\n            \"dpm_fast\",\n            \"dpm_adaptive\",\n            \"dpmpp_2s_ancestral\",\n            \"dpmpp_2s_ancestral_cfg_pp\",\n            \"dpmpp_sde\",\n            \"dpmpp_sde_gpu\",\n            \"dpmpp_2m\",\n            \"dpmpp_2m_cfg_pp\",\n            \"dpmpp_2m_sde\",\n            \"dpmpp_2m_sde_gpu\",\n            \"dpmpp_2m_sde_heun\",\n            \"dpmpp_2m_sde_heun_gpu\",\n            \"dpmpp_3m_sde\",\n            \"dpmpp_3m_sde_gpu\",\n            \"ddpm\",\n            \"lcm\",\n            \"ipndm\",\n            \"ipndm_v\",\n            \"deis\",\n            \"res_multistep\",\n            \"res_multistep_cfg_pp\",\n            \"res_multistep_ancestral\",\n            \"res_multistep_ancestral_cfg_pp\",\n            \"gradient_estimation\",\n            \"gradient_estimation_cfg_pp\",\n            \"er_sde\",\n            \"seeds_2\",\n            \"seeds_3\",\n            \"sa_solver\",\n            \"sa_solver_pece\",\n            \"ddim\",\n            \"uni_pc\",\n            \"uni_pc_bh2\"\n          ],\n          {\n            \"tooltip\": \"The algorithm used when sampling, this can affect the quality, speed, and style of the generated output.\"\n          }\n        ],\n        \"scheduler\": [\n          [\n            \"simple\",\n            \"sgm_uniform\",\n            \"karras\",\n            \"exponential\",\n            \"ddim_uniform\",\n            \"beta\",\n            \"normal\",\n            \"linear_quadratic\",\n            \"kl_optimal\"\n          ],\n          {\n            \"tooltip\": \"The scheduler controls how noise is gradually removed to form the image.\"\n          }\n        ],\n        \"positive\": [\n          \"CONDITIONING\",\n          {\n            \"tooltip\": \"The conditioning describing the attributes you want to include in the image.\"\n          }\n        ],\n        \"negative\": [\n          \"CONDITIONING\",\n          {\n            \"tooltip\": \"The conditioning describing the attributes you want to exclude from the image.\"\n          }\n        ],\n        \"latent_image\": [\n          \"LATENT\",\n          {\n            \"tooltip\": \"The latent image to denoise.\"\n          }\n        ],\n        \"denoise\": [\n          \"FLOAT\",\n          {\n            \"default\": 1.0,\n            \"min\": 0.0,\n            \"max\": 1.0,\n            \"step\": 0.01,\n            \"tooltip\": \"The amount of denoising applied, lower values will maintain the structure of the initial image allowing for image to image sampling.\"\n          }\n        ]\n      }\n    },\n    \"input_order\": {\n      \"required\": [\n        \"model\",\n        \"seed\",\n        \"steps\",\n        \"cfg\",\n        \"sampler_name\",\n        \"scheduler\",\n        \"positive\",\n        \"negative\",\n        \"latent_image\",\n        \"denoise\"\n      ]\n    },\n    \"is_input_list\": false,\n    \"output\": [\n      \"LATENT\"\n    ],\n    \"output_is_list\": [\n      false\n    ],\n    \"output_name\": [\n      \"LATENT\"\n    ],\n    \"name\": \"KSampler\",\n    \"display_name\": \"KSampler\",\n    \"description\": \"Uses the provided model, positive and negative conditioning to denoise the latent image.\",\n    \"python_module\": \"nodes\",\n    \"category\": \"sampling\",\n    \"output_node\": false,\n    \"has_intermediate_output\": false,\n    \"output_tooltips\": [\n      \"The denoised latent.\"\n    ],\n    \"search_aliases\": [\n      \"sampler\",\n      \"sample\",\n      \"generate\",\n      \"denoise\",\n      \"diffuse\",\n      \"txt2img\",\n      \"img2img\"\n    ]\n  },\n  \"SaveImage\": {\n    \"input\": {\n      \"required\": {\n        \"images\": [\n          \"IMAGE\",\n          {\n            \"tooltip\": \"The images to save.\"\n          }\n        ],\n        \"filename_prefix\": [\n          \"STRING\",\n          {\n            \"default\": \"ComfyUI\",\n            \"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.\"\n          }\n        ]\n      },\n      \"hidden\": {\n        \"prompt\": \"PROMPT\",\n        \"extra_pnginfo\": \"EXTRA_PNGINFO\"\n      }\n    },\n    \"input_order\": {\n      \"required\": [\n        \"images\",\n        \"filename_prefix\"\n      ],\n      \"hidden\": [\n        \"prompt\",\n        \"extra_pnginfo\"\n      ]\n    },\n    \"is_input_list\": false,\n    \"output\": [],\n    \"output_is_list\": [],\n    \"output_name\": [],\n    \"name\": \"SaveImage\",\n    \"display_name\": \"Save Image\",\n    \"description\": \"Saves the input images to your ComfyUI output directory.\",\n    \"python_module\": \"nodes\",\n    \"category\": \"image\",\n    \"output_node\": true,\n    \"has_intermediate_output\": false,\n    \"search_aliases\": [\n      \"save\",\n      \"save image\",\n      \"export image\",\n      \"output image\",\n      \"write image\",\n      \"download\"\n    ],\n    \"essentials_category\": \"Basics\"\n  },\n  \"VAEDecode\": {\n    \"input\": {\n      \"required\": {\n        \"samples\": [\n          \"LATENT\",\n          {\n            \"tooltip\": \"The latent to be decoded.\"\n          }\n        ],\n        \"vae\": [\n          \"VAE\",\n          {\n            \"tooltip\": \"The VAE model used for decoding the latent.\"\n          }\n        ]\n      }\n    },\n    \"input_order\": {\n      \"required\": [\n        \"samples\",\n        \"vae\"\n      ]\n    },\n    \"is_input_list\": false,\n    \"output\": [\n      \"IMAGE\"\n    ],\n    \"output_is_list\": [\n      false\n    ],\n    \"output_name\": [\n      \"IMAGE\"\n    ],\n    \"name\": \"VAEDecode\",\n    \"display_name\": \"VAE Decode\",\n    \"description\": \"Decodes latent images back into pixel space images.\",\n    \"python_module\": \"nodes\",\n    \"category\": \"latent\",\n    \"output_node\": false,\n    \"has_intermediate_output\": false,\n    \"output_tooltips\": [\n      \"The decoded image.\"\n    ],\n    \"search_aliases\": [\n      \"decode\",\n      \"decode latent\",\n      \"latent to image\",\n      \"render latent\"\n    ]\n  }\n}"
  },
  {
    "path": "tests/comfy_cli/fixtures/sd15_ui_workflow.json",
    "content": "{\n  \"id\": \"2ba0b800-2f13-4f21-b8d6-c6cdb0152cae\",\n  \"revision\": 0,\n  \"last_node_id\": 16,\n  \"last_link_id\": 9,\n  \"nodes\": [\n    {\n      \"id\": 4,\n      \"type\": \"CheckpointLoaderSimple\",\n      \"pos\": [\n        10,\n        300\n      ],\n      \"size\": [\n        320,\n        154.65625\n      ],\n      \"flags\": {},\n      \"order\": 0,\n      \"mode\": 0,\n      \"inputs\": [],\n      \"outputs\": [\n        {\n          \"name\": \"MODEL\",\n          \"type\": \"MODEL\",\n          \"slot_index\": 0,\n          \"links\": [\n            1\n          ]\n        },\n        {\n          \"name\": \"CLIP\",\n          \"type\": \"CLIP\",\n          \"slot_index\": 1,\n          \"links\": [\n            3,\n            5\n          ]\n        },\n        {\n          \"name\": \"VAE\",\n          \"type\": \"VAE\",\n          \"slot_index\": 2,\n          \"links\": [\n            8\n          ]\n        }\n      ],\n      \"properties\": {\n        \"Node name for S&R\": \"CheckpointLoaderSimple\",\n        \"cnr_id\": \"comfy-core\",\n        \"ver\": \"0.3.65\",\n        \"models\": [\n          {\n            \"name\": \"v1-5-pruned-emaonly-fp16.safetensors\",\n            \"url\": \"https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true\",\n            \"directory\": \"checkpoints\"\n          }\n        ]\n      },\n      \"widgets_values\": [\n        \"v1-5-pruned-emaonly-fp16.safetensors\"\n      ]\n    },\n    {\n      \"id\": 3,\n      \"type\": \"KSampler\",\n      \"pos\": [\n        920,\n        170\n      ],\n      \"size\": [\n        320,\n        480\n      ],\n      \"flags\": {},\n      \"order\": 8,\n      \"mode\": 0,\n      \"inputs\": [\n        {\n          \"name\": \"model\",\n          \"type\": \"MODEL\",\n          \"link\": 1\n        },\n        {\n          \"name\": \"positive\",\n          \"type\": \"CONDITIONING\",\n          \"link\": 4\n        },\n        {\n          \"name\": \"negative\",\n          \"type\": \"CONDITIONING\",\n          \"link\": 6\n        },\n        {\n          \"name\": \"latent_image\",\n          \"type\": \"LATENT\",\n          \"link\": 2\n        }\n      ],\n      \"outputs\": [\n        {\n          \"name\": \"LATENT\",\n          \"type\": \"LATENT\",\n          \"slot_index\": 0,\n          \"links\": [\n            7\n          ]\n        }\n      ],\n      \"properties\": {\n        \"Node name for S&R\": \"KSampler\",\n        \"cnr_id\": \"comfy-core\",\n        \"ver\": \"0.3.65\"\n      },\n      \"widgets_values\": [\n        685468484323813,\n        \"randomize\",\n        20,\n        8,\n        \"euler\",\n        \"normal\",\n        1\n      ]\n    },\n    {\n      \"id\": 8,\n      \"type\": \"VAEDecode\",\n      \"pos\": [\n        1000,\n        710\n      ],\n      \"size\": [\n        225,\n        96\n      ],\n      \"flags\": {},\n      \"order\": 9,\n      \"mode\": 0,\n      \"inputs\": [\n        {\n          \"name\": \"samples\",\n          \"type\": \"LATENT\",\n          \"link\": 7\n        },\n        {\n          \"name\": \"vae\",\n          \"type\": \"VAE\",\n          \"link\": 8\n        }\n      ],\n      \"outputs\": [\n        {\n          \"name\": \"IMAGE\",\n          \"type\": \"IMAGE\",\n          \"slot_index\": 0,\n          \"links\": [\n            9\n          ]\n        }\n      ],\n      \"properties\": {\n        \"Node name for S&R\": \"VAEDecode\",\n        \"cnr_id\": \"comfy-core\",\n        \"ver\": \"0.3.65\"\n      },\n      \"widgets_values\": []\n    },\n    {\n      \"id\": 9,\n      \"type\": \"SaveImage\",\n      \"pos\": [\n        1270,\n        170\n      ],\n      \"size\": [\n        470,\n        560\n      ],\n      \"flags\": {},\n      \"order\": 10,\n      \"mode\": 0,\n      \"inputs\": [\n        {\n          \"name\": \"images\",\n          \"type\": \"IMAGE\",\n          \"link\": 9\n        }\n      ],\n      \"outputs\": [],\n      \"properties\": {\n        \"cnr_id\": \"comfy-core\",\n        \"ver\": \"0.3.65\"\n      },\n      \"widgets_values\": [\n        \"SD1.5\"\n      ]\n    },\n    {\n      \"id\": 7,\n      \"type\": \"CLIPTextEncode\",\n      \"pos\": [\n        430,\n        530\n      ],\n      \"size\": [\n        420,\n        170\n      ],\n      \"flags\": {},\n      \"order\": 7,\n      \"mode\": 0,\n      \"inputs\": [\n        {\n          \"name\": \"clip\",\n          \"type\": \"CLIP\",\n          \"link\": 5\n        }\n      ],\n      \"outputs\": [\n        {\n          \"name\": \"CONDITIONING\",\n          \"type\": \"CONDITIONING\",\n          \"slot_index\": 0,\n          \"links\": [\n            6\n          ]\n        }\n      ],\n      \"properties\": {\n        \"Node name for S&R\": \"CLIPTextEncode\",\n        \"cnr_id\": \"comfy-core\",\n        \"ver\": \"0.3.65\"\n      },\n      \"widgets_values\": [\n        \"text, watermark\"\n      ],\n      \"color\": \"#223\",\n      \"bgcolor\": \"#335\"\n    },\n    {\n      \"id\": 5,\n      \"type\": \"EmptyLatentImage\",\n      \"pos\": [\n        490,\n        900\n      ],\n      \"size\": [\n        320,\n        168\n      ],\n      \"flags\": {},\n      \"order\": 1,\n      \"mode\": 0,\n      \"inputs\": [],\n      \"outputs\": [\n        {\n          \"name\": \"LATENT\",\n          \"type\": \"LATENT\",\n          \"slot_index\": 0,\n          \"links\": [\n            2\n          ]\n        }\n      ],\n      \"properties\": {\n        \"Node name for S&R\": \"EmptyLatentImage\",\n        \"cnr_id\": \"comfy-core\",\n        \"ver\": \"0.3.65\"\n      },\n      \"widgets_values\": [\n        512,\n        512,\n        1\n      ]\n    },\n    {\n      \"id\": 6,\n      \"type\": \"CLIPTextEncode\",\n      \"pos\": [\n        420,\n        220\n      ],\n      \"size\": [\n        430,\n        260\n      ],\n      \"flags\": {},\n      \"order\": 6,\n      \"mode\": 0,\n      \"inputs\": [\n        {\n          \"name\": \"clip\",\n          \"type\": \"CLIP\",\n          \"link\": 3\n        }\n      ],\n      \"outputs\": [\n        {\n          \"name\": \"CONDITIONING\",\n          \"type\": \"CONDITIONING\",\n          \"slot_index\": 0,\n          \"links\": [\n            4\n          ]\n        }\n      ],\n      \"properties\": {\n        \"Node name for S&R\": \"CLIPTextEncode\",\n        \"cnr_id\": \"comfy-core\",\n        \"ver\": \"0.3.65\"\n      },\n      \"widgets_values\": [\n        \"beautiful scenery nature glass bottle landscape, purple galaxy bottle,\"\n      ],\n      \"color\": \"#232\",\n      \"bgcolor\": \"#353\"\n    },\n    {\n      \"id\": 15,\n      \"type\": \"MarkdownNote\",\n      \"pos\": [\n        400,\n        -320\n      ],\n      \"size\": [\n        470,\n        430\n      ],\n      \"flags\": {},\n      \"order\": 2,\n      \"mode\": 0,\n      \"inputs\": [],\n      \"outputs\": [],\n      \"title\": \"Note: Prompt\",\n      \"properties\": {},\n      \"widgets_values\": [\n        \"**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:<br/>\\n\\ud83d\\udc49 Positive = \\u201cDo this\\u201d <br/>\\n\\ud83d\\udc49 Negative = \\u201cDon\\u2019t do this\\u201d\\n\\n\\nDifferent models may interpret prompts differently.<br/>\\nSome prefer short, simple phrases; others respond well to detailed descriptions or styles.\\nExperiment to see how each model reacts.\\n\\nAbout SD1.5:<br/>\\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\"\n      ],\n      \"color\": \"#432\",\n      \"bgcolor\": \"#000\"\n    },\n    {\n      \"id\": 14,\n      \"type\": \"MarkdownNote\",\n      \"pos\": [\n        1270,\n        780\n      ],\n      \"size\": [\n        470,\n        130\n      ],\n      \"flags\": {},\n      \"order\": 3,\n      \"mode\": 0,\n      \"inputs\": [],\n      \"outputs\": [],\n      \"title\": \"Note: Output\",\n      \"properties\": {},\n      \"widgets_values\": [\n        \"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.\"\n      ],\n      \"color\": \"#432\",\n      \"bgcolor\": \"#000\"\n    },\n    {\n      \"id\": 13,\n      \"type\": \"MarkdownNote\",\n      \"pos\": [\n        460,\n        1180\n      ],\n      \"size\": [\n        330,\n        163.953125\n      ],\n      \"flags\": {},\n      \"order\": 4,\n      \"mode\": 0,\n      \"inputs\": [],\n      \"outputs\": [],\n      \"title\": \"Note: Image size\",\n      \"properties\": {},\n      \"widgets_values\": [\n        \"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.\"\n      ],\n      \"color\": \"#432\",\n      \"bgcolor\": \"#000\"\n    },\n    {\n      \"id\": 11,\n      \"type\": \"MarkdownNote\",\n      \"pos\": [\n        -470,\n        160\n      ],\n      \"size\": [\n        400,\n        530.890625\n      ],\n      \"flags\": {},\n      \"order\": 5,\n      \"mode\": 0,\n      \"inputs\": [],\n      \"outputs\": [],\n      \"title\": \"Note: Model link\",\n      \"properties\": {},\n      \"widgets_values\": [\n        \"[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)\"\n      ],\n      \"color\": \"#432\",\n      \"bgcolor\": \"#000\"\n    }\n  ],\n  \"links\": [\n    [\n      1,\n      4,\n      0,\n      3,\n      0,\n      \"MODEL\"\n    ],\n    [\n      2,\n      5,\n      0,\n      3,\n      3,\n      \"LATENT\"\n    ],\n    [\n      3,\n      4,\n      1,\n      6,\n      0,\n      \"CLIP\"\n    ],\n    [\n      4,\n      6,\n      0,\n      3,\n      1,\n      \"CONDITIONING\"\n    ],\n    [\n      5,\n      4,\n      1,\n      7,\n      0,\n      \"CLIP\"\n    ],\n    [\n      6,\n      7,\n      0,\n      3,\n      2,\n      \"CONDITIONING\"\n    ],\n    [\n      7,\n      3,\n      0,\n      8,\n      0,\n      \"LATENT\"\n    ],\n    [\n      8,\n      4,\n      2,\n      8,\n      1,\n      \"VAE\"\n    ],\n    [\n      9,\n      8,\n      0,\n      9,\n      0,\n      \"IMAGE\"\n    ]\n  ],\n  \"groups\": [\n    {\n      \"id\": 1,\n      \"title\": \"Step 1 - Load model\",\n      \"bounding\": [\n        -40,\n        130,\n        420,\n        470\n      ],\n      \"color\": \"#3f789e\",\n      \"font_size\": 24,\n      \"flags\": {}\n    },\n    {\n      \"id\": 2,\n      \"title\": \"Step 3 - Image size\",\n      \"bounding\": [\n        400,\n        800,\n        480,\n        310\n      ],\n      \"color\": \"#3f789e\",\n      \"font_size\": 24,\n      \"flags\": {}\n    },\n    {\n      \"id\": 3,\n      \"title\": \"Step 2 - Prompt\",\n      \"bounding\": [\n        400,\n        130,\n        480,\n        640\n      ],\n      \"color\": \"#3f789e\",\n      \"font_size\": 24,\n      \"flags\": {}\n    }\n  ],\n  \"config\": {},\n  \"extra\": {\n    \"ds\": {\n      \"scale\": 0.5131581182307069,\n      \"offset\": [\n        979.5226642853634,\n        273.924658465434\n      ]\n    },\n    \"frontendVersion\": \"1.42.15\",\n    \"VHS_latentpreview\": false,\n    \"VHS_latentpreviewrate\": 0,\n    \"VHS_MetadataImage\": true,\n    \"VHS_KeepIntermediate\": true\n  },\n  \"version\": 0.4\n}"
  },
  {
    "path": "tests/comfy_cli/registry/test_api.py",
    "content": "import unittest\nfrom unittest.mock import MagicMock, patch\n\nfrom comfy_cli.registry import PyProjectConfig\nfrom comfy_cli.registry.api import RegistryAPI\nfrom comfy_cli.registry.types import ComfyConfig, License, ProjectConfig, URLs\n\n\nclass TestRegistryAPI(unittest.TestCase):\n    def setUp(self):\n        self.registry_api = RegistryAPI()\n        self.node_config = PyProjectConfig(\n            project=ProjectConfig(\n                name=\"test_node\",\n                description=\"A test node\",\n                version=\"0.1.0\",\n                requires_python=\">= 3.9\",\n                dependencies=[\"dep1\", \"dep2\"],\n                license=License(file=\"LICENSE\"),\n                urls=URLs(repository=\"https://github.com/test/test_node\"),\n            ),\n            tool_comfy=ComfyConfig(\n                publisher_id=\"123\",\n                display_name=\"Test Node\",\n                icon=\"https://example.com/icon.png\",\n            ),\n        )\n        self.token = \"dummy_token\"\n\n    @patch(\"os.getenv\")\n    def test_determine_base_url_dev(self, mock_getenv):\n        mock_getenv.return_value = \"dev\"\n        self.assertEqual(self.registry_api.determine_base_url(), \"http://localhost:8080\")\n\n    @patch(\"os.getenv\")\n    def test_determine_base_url_prod(self, mock_getenv):\n        mock_getenv.return_value = \"prod\"\n        self.assertEqual(self.registry_api.determine_base_url(), \"https://api.comfy.org\")\n\n    @patch(\"requests.post\")\n    def test_publish_node_version_success(self, mock_post):\n        mock_response = MagicMock()\n        mock_response.status_code = 201\n        mock_response.json.return_value = {\n            \"node_version\": {\n                \"id\": \"test_node\",\n                \"version\": \"0.1.0\",\n                \"changelog\": \"\",\n                \"dependencies\": [\"dep1\", \"dep2\"],\n                \"deprecated\": False,\n                \"downloadUrl\": \"https://example.com/download\",\n            },\n            \"signedUrl\": \"https://example.com/signed\",\n        }\n        mock_post.return_value = mock_response\n\n        response = self.registry_api.publish_node_version(self.node_config, self.token)\n        self.assertEqual(response.node_version.id, \"test_node\")\n        self.assertEqual(response.node_version.version, \"0.1.0\")\n        self.assertEqual(response.signedUrl, \"https://example.com/signed\")\n\n    @patch(\"requests.post\")\n    def test_publish_node_version_failure(self, mock_post):\n        mock_response = MagicMock()\n        mock_response.status_code = 400\n        mock_response.text = \"Bad Request\"\n        mock_post.return_value = mock_response\n\n        with self.assertRaises(Exception) as context:\n            self.registry_api.publish_node_version(self.node_config, self.token)\n        self.assertIn(\"Failed to publish node version\", str(context.exception))\n\n    @patch(\"requests.get\")\n    def test_list_all_nodes_success(self, mock_get):\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n        mock_response.json.return_value = {\n            \"nodes\": [\n                {\n                    \"id\": \"node1\",\n                    \"name\": \"Node 1\",\n                    \"description\": \"First node\",\n                    \"author\": \"Author 1\",\n                    \"license\": \"MIT\",\n                    \"icon\": \"https://example.com/icon1.png\",\n                    \"repository\": \"https://github.com/test/node1\",\n                    \"tags\": [\"tag1\", \"tag2\"],\n                    \"latest_version\": {\n                        \"id\": \"node1\",\n                        \"version\": \"1.0.0\",\n                        \"changelog\": \"\",\n                        \"dependencies\": [\"dep1\"],\n                        \"deprecated\": False,\n                        \"downloadUrl\": \"https://example.com/download1\",\n                    },\n                }\n            ]\n        }\n        mock_get.return_value = mock_response\n\n        nodes = self.registry_api.list_all_nodes()\n        self.assertEqual(len(nodes), 1)\n        self.assertEqual(nodes[0].id, \"node1\")\n        self.assertEqual(nodes[0].name, \"Node 1\")\n\n    @patch(\"requests.get\")\n    def test_list_all_nodes_failure(self, mock_get):\n        mock_response = MagicMock()\n        mock_response.status_code = 500\n        mock_response.text = \"Internal Server Error\"\n        mock_get.return_value = mock_response\n\n        with self.assertRaises(Exception) as context:\n            self.registry_api.list_all_nodes()\n        self.assertIn(\"Failed to retrieve nodes\", str(context.exception))\n\n    @patch(\"requests.get\")\n    def test_install_node_success(self, mock_get):\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n        mock_response.json.return_value = {\n            \"id\": \"node1\",\n            \"version\": \"1.0.0\",\n            \"changelog\": \"\",\n            \"dependencies\": [\"dep1\"],\n            \"deprecated\": False,\n            \"downloadUrl\": \"https://example.com/download1\",\n        }\n        mock_get.return_value = mock_response\n\n        node_version = self.registry_api.install_node(\"node1\")\n        self.assertEqual(node_version.id, \"node1\")\n        self.assertEqual(node_version.version, \"1.0.0\")\n\n    @patch(\"requests.get\")\n    def test_install_node_failure(self, mock_get):\n        mock_response = MagicMock()\n        mock_response.status_code = 404\n        mock_response.text = \"Not Found\"\n        mock_get.return_value = mock_response\n\n        with self.assertRaises(Exception) as context:\n            self.registry_api.install_node(\"node1\")\n        self.assertIn(\"Failed to install node\", str(context.exception))\n"
  },
  {
    "path": "tests/comfy_cli/registry/test_config_parser.py",
    "content": "import subprocess\nfrom unittest.mock import mock_open, patch\n\nimport pytest\nimport tomlkit\n\nfrom comfy_cli.registry.config_parser import (\n    _strip_url_credentials,\n    extract_node_configuration,\n    initialize_project_config,\n    validate_and_extract_accelerator_classifiers,\n    validate_and_extract_os_classifiers,\n    validate_version,\n)\nfrom comfy_cli.registry.types import (\n    License,\n    Model,\n    PyProjectConfig,\n    URLs,\n)\n\n\n@pytest.fixture\ndef mock_toml_data():\n    return {\n        \"project\": {\n            \"name\": \"test-project\",\n            \"description\": \"A test project\",\n            \"version\": \"1.0.0\",\n            \"requires-python\": \">=3.7\",\n            \"dependencies\": [\"requests\"],\n            \"license\": {\"file\": \"LICENSE\"},\n            \"urls\": {\n                \"Homepage\": \"https://example.com\",\n                \"Documentation\": \"https://docs.example.com\",\n                \"Repository\": \"https://github.com/example/test-project\",\n                \"Issues\": \"https://github.com/example/test-project/issues\",\n            },\n        },\n        \"tool\": {\n            \"comfy\": {\n                \"PublisherId\": \"test-publisher\",\n                \"DisplayName\": \"Test Project\",\n                \"Icon\": \"icon.png\",\n                \"Banner\": \"https://example.com/banner.png\",\n                \"Models\": [\n                    {\n                        \"location\": \"model1.bin\",\n                        \"model_url\": \"https://example.com/model1\",\n                    },\n                    {\n                        \"location\": \"model2.bin\",\n                        \"model_url\": \"https://example.com/model2\",\n                    },\n                ],\n            }\n        },\n    }\n\n\ndef test_extract_node_configuration_success(mock_toml_data):\n    with (\n        patch(\"os.path.isfile\", return_value=True),\n        patch(\"builtins.open\", mock_open()),\n        patch(\"tomlkit.load\", return_value=mock_toml_data),\n    ):\n        result = extract_node_configuration(\"fake_path.toml\")\n\n        assert isinstance(result, PyProjectConfig)\n        assert result.project.name == \"test-project\"\n        assert result.project.description == \"A test project\"\n        assert result.project.version == \"1.0.0\"\n        assert result.project.requires_python == \">=3.7\"\n        assert result.project.dependencies == [\"requests\"]\n        assert result.project.license == License(file=\"LICENSE\")\n        assert result.project.urls == URLs(\n            homepage=\"https://example.com\",\n            documentation=\"https://docs.example.com\",\n            repository=\"https://github.com/example/test-project\",\n            issues=\"https://github.com/example/test-project/issues\",\n        )\n        assert result.tool_comfy.publisher_id == \"test-publisher\"\n        assert result.tool_comfy.display_name == \"Test Project\"\n        assert result.tool_comfy.icon == \"icon.png\"\n        assert result.tool_comfy.banner_url == \"https://example.com/banner.png\"\n        assert len(result.tool_comfy.models) == 2\n        assert result.tool_comfy.models[0] == Model(location=\"model1.bin\", model_url=\"https://example.com/model1\")\n\n\n@pytest.mark.parametrize(\n    \"license_str\",\n    [\"MIT\", \"Apache-2.0\", \"GPL-3.0-or-later\", \"MIT License\"],\n)\ndef test_extract_node_configuration_license_spdx_string(license_str):\n    mock_data = {\n        \"project\": {\n            \"license\": license_str,\n        },\n    }\n    with (\n        patch(\"os.path.isfile\", return_value=True),\n        patch(\"builtins.open\", mock_open()),\n        patch(\"tomlkit.load\", return_value=mock_data),\n    ):\n        result = extract_node_configuration(\"fake_path.toml\")\n        assert result is not None, \"Expected PyProjectConfig, got None\"\n        assert isinstance(result, PyProjectConfig)\n        assert result.project.license == License(text=license_str)\n\n\ndef test_extract_node_configuration_license_text_dict():\n    mock_data = {\n        \"project\": {\n            \"license\": {\"text\": \"MIT License\\n\\nCopyright (c) 2023 Example Corp\\n\\nPermission is hereby granted...\"},\n        },\n    }\n    with (\n        patch(\"os.path.isfile\", return_value=True),\n        patch(\"builtins.open\", mock_open()),\n        patch(\"tomlkit.load\", return_value=mock_data),\n    ):\n        result = extract_node_configuration(\"fake_path.toml\")\n\n        assert result is not None, \"Expected PyProjectConfig, got None\"\n        assert isinstance(result, PyProjectConfig)\n        assert result.project.license == License(\n            text=\"MIT License\\n\\nCopyright (c) 2023 Example Corp\\n\\nPermission is hereby granted...\"\n        )\n\n\ndef test_extract_node_configuration_with_os_classifiers():\n    mock_data = {\n        \"project\": {\n            \"classifiers\": [\n                \"Operating System :: OS Independent\",\n                \"Operating System :: Microsoft :: Windows\",\n                \"Programming Language :: Python :: 3\",\n                \"Topic :: Software Development\",\n            ]\n        }\n    }\n    with (\n        patch(\"os.path.isfile\", return_value=True),\n        patch(\"builtins.open\", mock_open()),\n        patch(\"tomlkit.load\", return_value=mock_data),\n    ):\n        result = extract_node_configuration(\"fake_path.toml\")\n\n        assert result is not None\n        assert len(result.project.supported_os) == 2\n        assert \"OS Independent\" in result.project.supported_os\n        assert \"Microsoft :: Windows\" in result.project.supported_os\n\n\ndef test_extract_node_configuration_with_accelerator_classifiers():\n    mock_data = {\n        \"project\": {\n            \"classifiers\": [\n                \"Environment :: GPU :: NVIDIA CUDA\",\n                \"Environment :: GPU :: AMD ROCm\",\n                \"Environment :: GPU :: Intel Arc\",\n                \"Environment :: NPU :: Huawei Ascend\",\n                \"Environment :: GPU :: Apple Metal\",\n                \"Programming Language :: Python :: 3\",\n                \"Topic :: Software Development\",\n            ]\n        }\n    }\n    with (\n        patch(\"os.path.isfile\", return_value=True),\n        patch(\"builtins.open\", mock_open()),\n        patch(\"tomlkit.load\", return_value=mock_data),\n    ):\n        result = extract_node_configuration(\"fake_path.toml\")\n\n        assert result is not None\n        assert len(result.project.supported_accelerators) == 5\n        assert \"GPU :: NVIDIA CUDA\" in result.project.supported_accelerators\n        assert \"GPU :: AMD ROCm\" in result.project.supported_accelerators\n        assert \"GPU :: Intel Arc\" in result.project.supported_accelerators\n        assert \"NPU :: Huawei Ascend\" in result.project.supported_accelerators\n        assert \"GPU :: Apple Metal\" in result.project.supported_accelerators\n\n\ndef test_extract_node_configuration_with_comfyui_version():\n    mock_data = {\"project\": {\"dependencies\": [\"packge1>=2.0.0\", \"comfyui-frontend-package>=1.2.3\", \"package2>=1.0.0\"]}}\n    with (\n        patch(\"os.path.isfile\", return_value=True),\n        patch(\"builtins.open\", mock_open()),\n        patch(\"tomlkit.load\", return_value=mock_data),\n    ):\n        result = extract_node_configuration(\"fake_path.toml\")\n\n        assert result is not None\n        assert result.project.supported_comfyui_frontend_version == \">=1.2.3\"\n        assert len(result.project.dependencies) == 2\n        assert \"comfyui-frontend-package>=1.2.3\" not in result.project.dependencies\n        assert \"packge1>=2.0.0\" in result.project.dependencies\n        assert \"package2>=1.0.0\" in result.project.dependencies\n\n\ndef test_extract_node_configuration_with_requires_comfyui():\n    mock_data = {\"project\": {}, \"tool\": {\"comfy\": {\"requires-comfyui\": \"2.0.0\"}}}\n    with (\n        patch(\"os.path.isfile\", return_value=True),\n        patch(\"builtins.open\", mock_open()),\n        patch(\"tomlkit.load\", return_value=mock_data),\n    ):\n        result = extract_node_configuration(\"fake_path.toml\")\n\n        assert result is not None\n        assert result.project.supported_comfyui_version == \"2.0.0\"\n\n\ndef _write_pyproject(tmp_path, body: str) -> str:\n    \"\"\"Write a pyproject.toml in tmp_path and return its absolute path as a string.\"\"\"\n    p = tmp_path / \"pyproject.toml\"\n    p.write_text(body)\n    return str(p)\n\n\ndef test_dynamic_version_resolved_from_double_quoted_literal(tmp_path):\n    (tmp_path / \"pkg\").mkdir()\n    (tmp_path / \"pkg\" / \"__init__.py\").write_text('__version__ = \"1.2.3\"\\n')\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = \"pkg/__init__.py\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"1.2.3\"\n\n\ndef test_dynamic_version_resolved_from_VERSION_name(tmp_path):\n    (tmp_path / \"_version.py\").write_text('VERSION = \"2.0.0\"\\n')\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = \"_version.py\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"2.0.0\"\n\n\ndef test_dynamic_version_resolved_from_single_quotes(tmp_path):\n    (tmp_path / \"_v.py\").write_text(\"__version__ = '0.9.1'\\n\")\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = \"_v.py\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"0.9.1\"\n\n\ndef test_dynamic_version_resolved_with_type_annotation(tmp_path):\n    (tmp_path / \"_v.py\").write_text('__version__: str = \"3.4.5\"\\n')\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = \"_v.py\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"3.4.5\"\n\n\ndef test_dynamic_version_ignores_commented_line(tmp_path):\n    # The `^` anchor ensures a commented-out line is not matched.\n    (tmp_path / \"_v.py\").write_text('# __version__ = \"9.9.9\"\\n__version__ = \"1.0.0\"\\n')\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = \"_v.py\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"1.0.0\"\n\n\ndef test_dynamic_version_first_match_wins(tmp_path):\n    (tmp_path / \"_v.py\").write_text('__version__ = \"1.0.0\"\\n__version__ = \"2.0.0\"\\n')\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = \"_v.py\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"1.0.0\"\n\n\ndef test_static_version_wins_over_tool_comfy_version(tmp_path):\n    # Defensive: if a user accidentally has both, the static `project.version`\n    # wins without ever reading the file (no warning, no resolution).\n    (tmp_path / \"_v.py\").write_text('__version__ = \"9.9.9\"\\n')\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\nversion = \"1.0.0\"\\n\\n[tool.comfy.version]\\npath = \"_v.py\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"1.0.0\"\n\n\n@patch(\"typer.echo\")\ndef test_dynamic_version_without_tool_comfy_version_warns(mock_echo, tmp_path):\n    path = _write_pyproject(tmp_path, '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n')\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"\"\n    assert any(\"[tool.comfy.version].path\" in str(c) for c in mock_echo.call_args_list)\n\n\n@patch(\"typer.echo\")\ndef test_dynamic_version_absolute_path_rejected(mock_echo, tmp_path):\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = \"/etc/passwd\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"\"\n    assert any(\"must be relative\" in str(c) for c in mock_echo.call_args_list)\n\n\n@patch(\"typer.echo\")\ndef test_dynamic_version_windows_absolute_path_rejected(mock_echo, tmp_path):\n    # Ensure a Windows-style absolute path is also rejected when tests run\n    # on POSIX (and vice versa) — the check is OS-agnostic.\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = \\'C:\\\\Windows\\\\version.py\\'\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"\"\n    assert any(\"must be relative\" in str(c) for c in mock_echo.call_args_list)\n\n\n@patch(\"typer.echo\")\ndef test_dynamic_version_path_traversal_rejected(mock_echo, tmp_path):\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = \"../../etc/passwd\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"\"\n    assert any(\"inside the project directory\" in str(c) for c in mock_echo.call_args_list)\n\n\n@patch(\"typer.echo\")\ndef test_dynamic_version_missing_file_warns(mock_echo, tmp_path):\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = \"does_not_exist.py\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"\"\n    assert any(\"could not read\" in str(c) for c in mock_echo.call_args_list)\n\n\n@patch(\"typer.echo\")\ndef test_dynamic_version_no_match_warns(mock_echo, tmp_path):\n    (tmp_path / \"_v.py\").write_text('other_var = \"1.2.3\"\\nsome_other = 42\\n')\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = \"_v.py\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"\"\n    assert any(\"could not find\" in str(c) for c in mock_echo.call_args_list)\n\n\ndef test_dynamic_version_handles_utf8_bom(tmp_path):\n    # Windows editors that write a UTF-8 BOM must not defeat the `^` anchor.\n    (tmp_path / \"_v.py\").write_bytes(b'\\xef\\xbb\\xbf__version__ = \"1.2.3\"\\n')\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = \"_v.py\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"1.2.3\"\n\n\n@patch(\"typer.echo\")\ndef test_dynamic_version_invalid_utf8_warns(mock_echo, tmp_path):\n    # Non-UTF-8 content must not crash the parser (UnicodeDecodeError is a\n    # ValueError, not an OSError — must be caught explicitly).\n    (tmp_path / \"_v.py\").write_bytes(b\"\\xff\\xfe\\x00\\x00garbage bytes\")\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = \"_v.py\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"\"\n    assert any(\"could not read\" in str(c) for c in mock_echo.call_args_list)\n\n\n@patch(\"typer.echo\")\ndef test_dynamic_version_scalar_tool_comfy_version_warns(mock_echo, tmp_path):\n    # User misplaced a scalar version under [tool.comfy] instead of [project].\n    # Warning should name the shape problem, not the path problem.\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy]\\nversion = \"1.2.3\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"\"\n    assert any(\"must be a table\" in str(c) for c in mock_echo.call_args_list)\n\n\n@patch(\"typer.echo\")\ndef test_malformed_dynamic_scalar_string_warns(mock_echo, tmp_path):\n    # User wrote `dynamic = \"version\"` (scalar) instead of `dynamic = [\"version\"]`.\n    # Silent-skip would leave them confused; warn explicitly.\n    (tmp_path / \"_v.py\").write_text('__version__ = \"1.0.0\"\\n')\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = \"version\"\\n\\n[tool.comfy.version]\\npath = \"_v.py\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"\"\n    assert any(\"must be an array of strings\" in str(c) for c in mock_echo.call_args_list)\n\n\ndef test_dynamic_version_indented_only_does_not_match(tmp_path):\n    # Regex anchor `^` must reject indented `__version__` assignments (inside\n    # classes/functions). File has ONLY the indented form — expect no match\n    # and empty version.\n    (tmp_path / \"_v.py\").write_text('class Foo:\\n    __version__ = \"1.2.3\"\\n')\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = \"_v.py\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"\"\n\n\ndef test_dynamic_version_trailing_inline_comment_resolves(tmp_path):\n    # `__version__ = \"1.2.3\"  # stable` must resolve to \"1.2.3\" (regex stops\n    # capture at the closing quote, trailing comment ignored).\n    (tmp_path / \"_v.py\").write_text('__version__ = \"1.2.3\"  # stable release\\n')\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = \"_v.py\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"1.2.3\"\n\n\n@patch(\"typer.echo\")\ndef test_dynamic_version_path_is_directory_warns(mock_echo, tmp_path):\n    # `path` pointing at a directory must degrade gracefully (IsADirectoryError\n    # is an OSError subclass) and surface a \"could not read\" warning.\n    (tmp_path / \"subdir\").mkdir()\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = \"subdir\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"\"\n    assert any(\"could not read\" in str(c) for c in mock_echo.call_args_list)\n\n\ndef test_padded_static_version_is_stripped(tmp_path):\n    # Static `version = \"  1.0.0  \"` must be normalized — registries should not\n    # receive whitespace padding.\n    path = _write_pyproject(tmp_path, '[project]\\nname = \"x\"\\nversion = \"  1.0.0  \"\\n')\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"1.0.0\"\n\n\n@patch(\"typer.echo\")\ndef test_dynamic_version_non_string_path_warns_as_type_error(mock_echo, tmp_path):\n    # A non-string `path` value must produce a type warning, not a misleading\n    # \"could not read file `42`\" OS error.\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = 42\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"\"\n    assert any(\"must be a string\" in str(c) for c in mock_echo.call_args_list)\n    # Must NOT fall through to an OS \"could not read\" warning\n    assert not any(\"could not read\" in str(c) for c in mock_echo.call_args_list)\n\n\n@patch(\"typer.echo\")\ndef test_static_version_happy_path_emits_no_version_warnings(mock_echo, tmp_path):\n    # Regression guard for the common case: static version, no dynamic, no\n    # [tool.comfy.version]. Must not emit ANY version/dynamic-related warning\n    # (only unrelated warnings like the pre-existing \"License...\" one are allowed).\n    path = _write_pyproject(tmp_path, '[project]\\nname = \"x\"\\nversion = \"1.2.3\"\\n')\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"1.2.3\"\n    noisy = [\n        str(c)\n        for c in mock_echo.call_args_list\n        if \"version\" in str(c).lower() or \"dynamic\" in str(c).lower() or \"tool.comfy\" in str(c).lower()\n    ]\n    assert noisy == [], f\"Unexpected version/dynamic warnings on happy path: {noisy}\"\n\n\n# --- Fix K: non-dict `project` / `tool` degrade gracefully ---\n\n\ndef test_malformed_toml_does_not_crash(tmp_path):\n    # Invalid TOML (syntax error) must not crash the parser — scanning\n    # contexts would lose the whole pack inventory otherwise.\n    (tmp_path / \"pyproject.toml\").write_text('[project\\nname = \"x\"\\n')  # missing `]`\n    result = extract_node_configuration(str(tmp_path / \"pyproject.toml\"))\n    assert result is None  # graceful None return, no exception\n\n\ndef test_pyproject_with_utf8_bom_parses_successfully(tmp_path):\n    # Windows editors (e.g., Notepad with legacy settings, Visual Studio) write\n    # a UTF-8 BOM on save. `encoding=\"utf-8-sig\"` strips it transparently; without\n    # this, tomlkit sees `﻿` as the first character and reports a cryptic\n    # `Empty key at line 1 col 0`.\n    (tmp_path / \"pyproject.toml\").write_bytes(b'\\xef\\xbb\\xbf[project]\\nname = \"x\"\\nversion = \"1.0.0\"\\n')\n    result = extract_node_configuration(str(tmp_path / \"pyproject.toml\"))\n    assert result is not None\n    assert result.project.name == \"x\"\n    assert result.project.version == \"1.0.0\"\n\n\ndef test_pyproject_with_invalid_utf8_returns_none_gracefully(tmp_path):\n    # `UnicodeDecodeError` is a `ValueError`, not an `OSError`, so it must be in\n    # the except tuple explicitly. Without it, a pyproject.toml with non-UTF-8\n    # bytes raises a raw traceback instead of the friendly error shown for every\n    # other file-read failure.\n    (tmp_path / \"pyproject.toml\").write_bytes(b'[project]\\nname = \"x\"\\nx = \"\\xff\\xfe garbage\"\\n')\n    result = extract_node_configuration(str(tmp_path / \"pyproject.toml\"))\n    assert result is None\n\n\n@patch(\"typer.echo\")\ndef test_static_version_non_string_scalar_rejected(mock_echo, tmp_path):\n    # PEP 621 requires `version` to be a string. A non-string scalar must now\n    # produce a typed warning, not be silently coerced via `str()`.\n    path = _write_pyproject(tmp_path, '[project]\\nname = \"x\"\\nversion = 1\\n')\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"\"\n    call_strs = [str(c) for c in mock_echo.call_args_list]\n    assert any(\"`project.version` must be a string\" in s for s in call_strs)\n\n\n@patch(\"typer.echo\")\ndef test_static_version_array_rejected(mock_echo, tmp_path):\n    # Without the type check, `str(['1', '2'])` would POST `\"['1', '2']\"` to\n    # the registry. Must be rejected.\n    path = _write_pyproject(tmp_path, '[project]\\nname = \"x\"\\nversion = [\"1\", \"2\"]\\n')\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"\"\n    call_strs = [str(c) for c in mock_echo.call_args_list]\n    assert any(\"`project.version` must be a string\" in s for s in call_strs)\n\n\n@patch(\"typer.echo\")\ndef test_static_version_inline_table_rejected(mock_echo, tmp_path):\n    # Users conflating PEP 621 static and our `[tool.comfy.version]` might write\n    # `version = { path = \"_v.py\" }`. Without the type check this POSTed as\n    # `\"{'path': '_v.py'}\"`. Catch it up front.\n    path = _write_pyproject(tmp_path, '[project]\\nname = \"x\"\\nversion = { path = \"_v.py\" }\\n')\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"\"\n    call_strs = [str(c) for c in mock_echo.call_args_list]\n    assert any(\"`project.version` must be a string\" in s for s in call_strs)\n\n\n@patch(\"typer.echo\")\ndef test_project_scalar_at_root_does_not_crash(mock_echo, tmp_path):\n    # Malformed TOML: `project = \"hello\"` at root. Must not crash — used to\n    # raise AttributeError: 'String' object has no attribute 'get'.\n    path = _write_pyproject(tmp_path, 'project = \"hello\"\\n[tool.comfy]\\nPublisherId = \"x\"\\n')\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"\"\n    assert any(\"`project` in pyproject.toml must be a table\" in str(c) for c in mock_echo.call_args_list)\n\n\n@pytest.mark.parametrize(\"value\", [\"0\", \"0.0\", \"false\", \"[]\", \"{}\"])\n@patch(\"typer.echo\")\ndef test_static_version_falsy_non_string_rejected(mock_echo, tmp_path, value):\n    # Regression guard: the type check must fire for FALSY non-strings too\n    # (`version = 0`, `version = 0.0`, `version = false`, `version = []`,\n    # `version = {}`). Earlier the truthy check (`if static_version:`) gated\n    # the isinstance check, so these silently fell through to the dynamic\n    # branch and the user only saw the downstream \"project version is empty\"\n    # error.\n    path = _write_pyproject(tmp_path, f'[project]\\nname = \"x\"\\nversion = {value}\\n')\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"\"\n    call_strs = [str(c) for c in mock_echo.call_args_list]\n    assert any(\"`project.version` must be a string\" in s for s in call_strs), (\n        f\"value={value}: no type warning; saw {call_strs}\"\n    )\n\n\n# --- Fix A (padded-static): strip semantics, already tested; add padded-dynamic variant ---\n\n\ndef test_dynamic_version_padded_literal_is_stripped(tmp_path):\n    # `__version__ = \"  1.2.3  \"` — the `.strip()` in _resolve_dynamic_version\n    # already handles this, pinning the behavior here for regression safety.\n    (tmp_path / \"_v.py\").write_text('__version__ = \"  1.2.3  \"\\n')\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = \"_v.py\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"1.2.3\"\n\n\n# --- Fix N: falsy-but-typed path values must trigger the type warning ---\n\n\n@patch(\"typer.echo\")\ndef test_dynamic_version_empty_path_string_warns_as_not_set(mock_echo, tmp_path):\n    # `path = \"\"` explicitly set to empty string — equivalent to unset.\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = \"\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"\"\n    assert any(\"path` is not set\" in str(c) for c in mock_echo.call_args_list)\n\n\n@patch(\"typer.echo\")\ndef test_dynamic_version_table_missing_path_key_warns_as_not_set(mock_echo, tmp_path):\n    # `[tool.comfy.version]` table exists but has no `path` key.\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"\"\n    assert any(\"path` is not set\" in str(c) for c in mock_echo.call_args_list)\n\n\n@patch(\"typer.echo\")\ndef test_falsy_nonstring_path_values_warn_as_type_mismatch(mock_echo, tmp_path):\n    # `path = 0 / false / [] / {}` are all truthy-falsy edge cases. They must\n    # produce a \"must be a string\" warning, not the misleading \"path is not set\".\n    for falsy in [\"0\", \"false\", \"[]\", \"{}\"]:\n        mock_echo.reset_mock()\n        path = _write_pyproject(\n            tmp_path,\n            f'[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = {falsy}\\n',\n        )\n        result = extract_node_configuration(path)\n        assert result is not None\n        assert result.project.version == \"\"\n        call_strs = [str(c) for c in mock_echo.call_args_list]\n        assert any(\"must be a string\" in s for s in call_strs), f\"falsy={falsy}: no type warning\"\n        assert not any(\"path` is not set\" in s for s in call_strs), f\"falsy={falsy}: got misleading 'not set' warning\"\n\n\n# --- Backslash in value: regex rejects, surfaces as \"could not find\" ---\n\n\n@patch(\"typer.echo\")\ndef test_dynamic_version_backslash_in_value_not_matched(mock_echo, tmp_path):\n    # The regex excludes `\\` from the value class entirely, so any escape\n    # sequence in the literal causes the regex to fail to match. Users get\n    # a clear \"could not find\" warning rather than a silently misinterpreted\n    # value. PEP 440 versions are ASCII-only so this is a clean fail-closed\n    # contract; users with auto-generated `__version__` containing escapes\n    # must clean up their source.\n    (tmp_path / \"_v.py\").write_text('__version__ = \"1.0\\\\n\"\\n')\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = \"_v.py\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"\"\n    assert any(\"could not find\" in str(c) for c in mock_echo.call_args_list)\n\n\n# --- H2 (Round 5): adjacent-string-literal concatenation detection ---\n\n\n@patch(\"typer.echo\")\ndef test_dynamic_version_adjacent_literals_double_quote_warns(mock_echo, tmp_path):\n    # Python evaluates `\"1.\" \"2.3\"` as `\"1.2.3\"` via implicit concatenation.\n    # Without this check, the regex captures only `\"1.\"` and we silently POST\n    # the wrong version. The same-line look-ahead rejects concatenation so the\n    # publish-layer guard exits 1 instead of shipping `\"1.\"` to the registry.\n    (tmp_path / \"_v.py\").write_text('__version__ = \"1.\" \"2.3\"\\n')\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = \"_v.py\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"\"\n    call_strs = [str(c) for c in mock_echo.call_args_list]\n    assert any(\"adjacent-string-literal concatenation\" in s for s in call_strs), (\n        f\"no concatenation warning; saw {call_strs}\"\n    )\n\n\n@patch(\"typer.echo\")\ndef test_dynamic_version_adjacent_literals_no_whitespace_warns(mock_echo, tmp_path):\n    # Python accepts `\"1.\"\"2.3\"` (no whitespace between) — still concatenation.\n    (tmp_path / \"_v.py\").write_text('__version__ = \"1.\"\"2.3\"\\n')\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = \"_v.py\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"\"\n    call_strs = [str(c) for c in mock_echo.call_args_list]\n    assert any(\"adjacent-string-literal concatenation\" in s for s in call_strs)\n\n\n@patch(\"typer.echo\")\ndef test_dynamic_version_adjacent_literals_single_quote_warns(mock_echo, tmp_path):\n    # Detection must fire for single-quoted literals too.\n    (tmp_path / \"_v.py\").write_text(\"__version__ = '1.' '2.3'\\n\")\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = \"_v.py\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"\"\n    call_strs = [str(c) for c in mock_echo.call_args_list]\n    assert any(\"adjacent-string-literal concatenation\" in s for s in call_strs)\n\n\n@patch(\"typer.echo\")\ndef test_dynamic_version_adjacent_literals_mixed_quotes_warns(mock_echo, tmp_path):\n    # Python allows mixing quote styles across adjacent literals: `\"1.\" '2.3'`\n    # evaluates to `\"1.2.3\"`. The check must inspect for ANY quote, not just\n    # the matched quote, so a `\"...\" '...'` or `'...' \"...\"` pair still fires.\n    (tmp_path / \"_v.py\").write_text(\"__version__ = \\\"1.\\\" '2.3'\\n\")\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = \"_v.py\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"\"\n    call_strs = [str(c) for c in mock_echo.call_args_list]\n    assert any(\"adjacent-string-literal concatenation\" in s for s in call_strs)\n\n\ndef test_dynamic_version_semicolon_after_literal_still_resolves(tmp_path):\n    # Negative control: `; x = 1` on the same line must NOT trigger the\n    # concatenation check (`;` isn't a quote). Pins the narrow look-ahead\n    # so a future broadening to \"any trailing content\" can't silently\n    # regress this case.\n    (tmp_path / \"_v.py\").write_text('__version__ = \"1.2.3\"; x = 1\\n')\n    path = _write_pyproject(\n        tmp_path,\n        '[project]\\nname = \"x\"\\ndynamic = [\"version\"]\\n\\n[tool.comfy.version]\\npath = \"_v.py\"\\n',\n    )\n    result = extract_node_configuration(path)\n    assert result is not None\n    assert result.project.version == \"1.2.3\"\n\n\ndef test_validate_and_extract_os_classifiers_valid():\n    \"\"\"Test OS validation with valid classifiers.\"\"\"\n    classifiers = [\n        \"Operating System :: Microsoft :: Windows\",\n        \"Operating System :: POSIX :: Linux\",\n        \"Operating System :: MacOS\",\n        \"Operating System :: OS Independent\",\n        \"Programming Language :: Python :: 3\",\n    ]\n    result = validate_and_extract_os_classifiers(classifiers)\n    expected = [\"Microsoft :: Windows\", \"POSIX :: Linux\", \"MacOS\", \"OS Independent\"]\n    assert result == expected\n\n\n@patch(\"typer.echo\")\ndef test_validate_and_extract_os_classifiers_invalid(mock_echo):\n    \"\"\"Test OS validation with invalid classifiers.\"\"\"\n    classifiers = [\n        \"Operating System :: Microsoft :: Windows\",\n        \"Operating System :: Linux\",  # Invalid - should be \"POSIX :: Linux\"\n        \"Programming Language :: Python :: 3\",\n    ]\n    result = validate_and_extract_os_classifiers(classifiers)\n    assert result == []\n    mock_echo.assert_called_once()\n    assert \"Invalid Operating System classifier found\" in mock_echo.call_args[0][0]\n\n\ndef test_validate_and_extract_accelerator_classifiers_valid():\n    \"\"\"Test accelerator validation with valid classifiers.\"\"\"\n    classifiers = [\n        \"Environment :: GPU :: NVIDIA CUDA\",\n        \"Environment :: GPU :: AMD ROCm\",\n        \"Environment :: GPU :: Intel Arc\",\n        \"Environment :: NPU :: Huawei Ascend\",\n        \"Environment :: GPU :: Apple Metal\",\n        \"Programming Language :: Python :: 3\",\n    ]\n    result = validate_and_extract_accelerator_classifiers(classifiers)\n    expected = [\n        \"GPU :: NVIDIA CUDA\",\n        \"GPU :: AMD ROCm\",\n        \"GPU :: Intel Arc\",\n        \"NPU :: Huawei Ascend\",\n        \"GPU :: Apple Metal\",\n    ]\n    assert result == expected\n\n\n@patch(\"typer.echo\")\ndef test_validate_and_extract_accelerator_classifiers_invalid(mock_echo):\n    \"\"\"Test accelerator validation with invalid classifiers.\"\"\"\n    classifiers = [\n        \"Environment :: GPU :: NVIDIA CUDA\",\n        \"Environment :: GPU :: Invalid GPU\",  # Invalid\n        \"Programming Language :: Python :: 3\",\n    ]\n    result = validate_and_extract_accelerator_classifiers(classifiers)\n    assert result == []\n    mock_echo.assert_called_once()\n    assert \"Invalid Environment classifier found\" in mock_echo.call_args[0][0]\n\n\ndef test_validate_version_valid():\n    \"\"\"Test version validation with valid versions.\"\"\"\n    valid_versions = [\n        \"1.1.1\",\n        \">=1.0.0\",\n        \"==2.1.0-beta\",\n        \"1.5.2\",\n        \"~=3.0.0\",\n        \"!=1.2.3\",\n        \">2.0.0\",\n        \"<3.0.0\",\n        \"<=4.0.0\",\n        \"<>1.0.0\",\n        \"=1.0.0\",\n        \"1.0.0-alpha1\",\n        \">=1.0.0,<2.0.0\",\n        \"==1.2.3,!=1.2.4\",\n        \">=1.0.0,<=2.0.0,!=1.5.0\",\n        \"1.0.0,2.0.0\",\n        \">1.0.0,<2.0.0,!=1.5.0-beta\",\n    ]\n\n    for version in valid_versions:\n        result = validate_version(version, \"test_field\")\n        assert result == version, f\"Version {version} should be valid\"\n\n\n@patch(\"typer.echo\")\ndef test_validate_version_invalid(mock_echo):\n    \"\"\"Test version validation with invalid versions.\"\"\"\n    invalid_versions = [\n        \"1.0\",  # Missing patch version\n        \">=abc\",  # Invalid version format\n        \"invalid-version\",  # Completely invalid\n        \"1.0.0.0\",  # Too many version parts\n        \">>1.0.0\",  # Invalid operator\n        \">=1.0.0,invalid\",\n        \"1.0,2.0.0\",\n        \">=1.0.0,>=abc\",\n    ]\n\n    for version in invalid_versions:\n        result = validate_version(version, \"test_field\")\n        assert result == \"\", f\"Version {version} should be invalid\"\n\n    assert mock_echo.call_count == len(invalid_versions)\n\n\n@pytest.mark.parametrize(\n    \"url, expected\",\n    [\n        (\"https://github.com/user/repo.git\", \"https://github.com/user/repo.git\"),\n        (\"https://ghp_xxxx@github.com/user/repo.git\", \"https://github.com/user/repo.git\"),\n        (\"https://user:ghp_xxxx@github.com/user/repo.git\", \"https://github.com/user/repo.git\"),\n        (\"https://oauth2:token@gitlab.com:8443/user/repo.git\", \"https://gitlab.com:8443/user/repo.git\"),\n        (\"git@github.com:user/repo.git\", \"git@github.com:user/repo.git\"),\n        (\"https://user:@github.com/user/repo.git\", \"https://github.com/user/repo.git\"),\n        (\"https://:pass@github.com/user/repo.git\", \"https://github.com/user/repo.git\"),\n        (\"http://token@example.com/repo.git\", \"http://example.com/repo.git\"),\n        (\"https://user:pass@[::1]:8080/repo.git\", \"https://[::1]:8080/repo.git\"),\n        (\"git://github.com/user/repo.git\", \"git://github.com/user/repo.git\"),\n        (\"https://github.com:443/user/repo.git\", \"https://github.com:443/user/repo.git\"),\n        (\"ssh://git@github.com/user/repo.git\", \"ssh://git@github.com/user/repo.git\"),\n    ],\n)\ndef test_strip_url_credentials(url, expected):\n    assert _strip_url_credentials(url) == expected\n\n\ndef test_initialize_project_config_strips_credentials(tmp_path, monkeypatch):\n    monkeypatch.chdir(tmp_path)\n    subprocess.run([\"git\", \"init\"], cwd=tmp_path, check=True, capture_output=True)\n    subprocess.run(\n        [\"git\", \"remote\", \"add\", \"origin\", \"https://ghp_secret@github.com/user/ComfyUI-MyNode.git\"],\n        cwd=tmp_path,\n        check=True,\n        capture_output=True,\n    )\n    initialize_project_config()\n    with open(tmp_path / \"pyproject.toml\") as f:\n        data = tomlkit.parse(f.read())\n    urls = data[\"project\"][\"urls\"]\n    assert urls[\"Repository\"] == \"https://github.com/user/ComfyUI-MyNode\"\n    assert urls[\"Documentation\"] == \"https://github.com/user/ComfyUI-MyNode/wiki\"\n    assert urls[\"Bug Tracker\"] == \"https://github.com/user/ComfyUI-MyNode/issues\"\n    assert \"ghp_secret\" not in tomlkit.dumps(data)\n\n\ndef test_initialize_project_config_clean_https(tmp_path, monkeypatch):\n    monkeypatch.chdir(tmp_path)\n    subprocess.run([\"git\", \"init\"], cwd=tmp_path, check=True, capture_output=True)\n    subprocess.run(\n        [\"git\", \"remote\", \"add\", \"origin\", \"https://github.com/user/ComfyUI-MyNode.git\"],\n        cwd=tmp_path,\n        check=True,\n        capture_output=True,\n    )\n    initialize_project_config()\n    with open(tmp_path / \"pyproject.toml\") as f:\n        data = tomlkit.parse(f.read())\n    urls = data[\"project\"][\"urls\"]\n    assert urls[\"Repository\"] == \"https://github.com/user/ComfyUI-MyNode\"\n    assert urls[\"Documentation\"] == \"https://github.com/user/ComfyUI-MyNode/wiki\"\n    assert urls[\"Bug Tracker\"] == \"https://github.com/user/ComfyUI-MyNode/issues\"\n\n\ndef test_initialize_project_config_ssh_remote(tmp_path, monkeypatch):\n    monkeypatch.chdir(tmp_path)\n    subprocess.run([\"git\", \"init\"], cwd=tmp_path, check=True, capture_output=True)\n    subprocess.run(\n        [\"git\", \"remote\", \"add\", \"origin\", \"git@github.com:user/ComfyUI-TestNode.git\"],\n        cwd=tmp_path,\n        check=True,\n        capture_output=True,\n    )\n    initialize_project_config()\n    with open(tmp_path / \"pyproject.toml\") as f:\n        data = tomlkit.parse(f.read())\n    urls = data[\"project\"][\"urls\"]\n    assert urls[\"Repository\"] == \"https://github.com/user/ComfyUI-TestNode\"\n    assert urls[\"Documentation\"] == \"https://github.com/user/ComfyUI-TestNode/wiki\"\n    assert urls[\"Bug Tracker\"] == \"https://github.com/user/ComfyUI-TestNode/issues\"\n    assert data[\"project\"][\"name\"] == \"testnode\"\n    assert data[\"tool\"][\"comfy\"][\"DisplayName\"] == \"ComfyUI-TestNode\"\n\n\n# Issue #431: requirements.txt → pyproject.toml migration must produce\n# valid PEP 508 dependency specifiers. Inline comments, full-line comments,\n# and pip-specific options (-r, -e, --index-url, ...) are not valid deps.\n\n\ndef _init_git_repo_with_reqs(tmp_path, requirements_content: str) -> None:\n    subprocess.run([\"git\", \"init\"], cwd=tmp_path, check=True, capture_output=True)\n    subprocess.run(\n        [\"git\", \"remote\", \"add\", \"origin\", \"https://github.com/user/ComfyUI-TestNode.git\"],\n        cwd=tmp_path,\n        check=True,\n        capture_output=True,\n    )\n    (tmp_path / \"requirements.txt\").write_text(requirements_content)\n\n\ndef test_initialize_project_config_strips_inline_comments(tmp_path, monkeypatch):\n    monkeypatch.chdir(tmp_path)\n    _init_git_repo_with_reqs(\n        tmp_path,\n        \"matplotlib>=3.3.0  # For visualization\\nnumpy>=1.0 # trailing\\n\",\n    )\n    initialize_project_config()\n    with open(tmp_path / \"pyproject.toml\") as f:\n        data = tomlkit.parse(f.read())\n    deps = [str(d) for d in data[\"project\"][\"dependencies\"]]\n    assert deps == [\"matplotlib>=3.3.0\", \"numpy>=1.0\"]\n\n\ndef test_initialize_project_config_skips_full_line_comments(tmp_path, monkeypatch):\n    monkeypatch.chdir(tmp_path)\n    _init_git_repo_with_reqs(\n        tmp_path,\n        \"# heading comment\\nfoo>=1.0\\n  # indented comment\\nbar\\n\",\n    )\n    initialize_project_config()\n    with open(tmp_path / \"pyproject.toml\") as f:\n        data = tomlkit.parse(f.read())\n    deps = [str(d) for d in data[\"project\"][\"dependencies\"]]\n    assert deps == [\"foo>=1.0\", \"bar\"]\n\n\ndef test_initialize_project_config_skips_pip_options(tmp_path, monkeypatch, capsys):\n    # `-r`, `-e`, `-c`, `--index-url`, `--extra-index-url`, `--find-links`\n    # are pip-requirements-file syntax, not PEP 508 dep specifiers. They must\n    # not land in [project.dependencies] where downstream build tools will\n    # error trying to parse them. Each skipped line must also produce a\n    # visible warning so silent data loss is avoided.\n    monkeypatch.chdir(tmp_path)\n    _init_git_repo_with_reqs(\n        tmp_path,\n        \"-r other.txt\\n\"\n        \"-e .\\n\"\n        \"--index-url https://pypi.org/simple\\n\"\n        \"--extra-index-url https://example.com/simple\\n\"\n        \"--find-links ./local-wheels\\n\"\n        \"foo>=1.0\\n\",\n    )\n    initialize_project_config()\n    with open(tmp_path / \"pyproject.toml\") as f:\n        data = tomlkit.parse(f.read())\n    deps = [str(d) for d in data[\"project\"][\"dependencies\"]]\n    assert deps == [\"foo>=1.0\"]\n    out = capsys.readouterr().out\n    for dropped in [\"-r other.txt\", \"-e .\", \"--index-url\", \"--extra-index-url\", \"--find-links\"]:\n        assert dropped in out, f\"missing skip warning for {dropped!r}\"\n\n\ndef test_initialize_project_config_preserves_vcs_subdirectory_fragment(tmp_path, monkeypatch):\n    # Regression guard against a naive `split(\"#\")[0]` fix — VCS fragments\n    # must survive because `#` is only a comment marker when preceded by\n    # whitespace (pip's rule).\n    monkeypatch.chdir(tmp_path)\n    _init_git_repo_with_reqs(\n        tmp_path,\n        \"git+https://github.com/org/mono.git#subdirectory=pkg\\n\",\n    )\n    initialize_project_config()\n    with open(tmp_path / \"pyproject.toml\") as f:\n        data = tomlkit.parse(f.read())\n    deps = [str(d) for d in data[\"project\"][\"dependencies\"]]\n    assert deps == [\"git+https://github.com/org/mono.git#subdirectory=pkg\"]\n\n\ndef test_initialize_project_config_vcs_with_inline_comment(tmp_path, monkeypatch):\n    monkeypatch.chdir(tmp_path)\n    _init_git_repo_with_reqs(\n        tmp_path,\n        \"git+https://github.com/org/mono.git#subdirectory=pkg  # monorepo dep\\n\",\n    )\n    initialize_project_config()\n    with open(tmp_path / \"pyproject.toml\") as f:\n        data = tomlkit.parse(f.read())\n    deps = [str(d) for d in data[\"project\"][\"dependencies\"]]\n    assert deps == [\"git+https://github.com/org/mono.git#subdirectory=pkg\"]\n"
  },
  {
    "path": "tests/comfy_cli/test_aria2_download.py",
    "content": "\"\"\"Tests for aria2 RPC download support.\"\"\"\n\nimport sys\nfrom types import ModuleType\nfrom unittest.mock import MagicMock, Mock, patch\n\nimport pytest\n\nfrom comfy_cli import constants\nfrom comfy_cli.file_utils import DownloadException, _download_file_aria2, download_file\n\n# ---------------------------------------------------------------------------\n# Fixtures\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture()\ndef aria2_env(monkeypatch):\n    \"\"\"Set the aria2 environment variables.\"\"\"\n    monkeypatch.setenv(constants.ARIA2_SERVER_ENV_KEY, \"http://localhost:6800\")\n    monkeypatch.setenv(constants.ARIA2_SECRET_ENV_KEY, \"mysecret\")\n\n\n@pytest.fixture()\ndef fake_aria2p():\n    \"\"\"Inject a fake aria2p module into sys.modules so import aria2p succeeds.\"\"\"\n    mod = ModuleType(\"aria2p\")\n    mod.Client = MagicMock()\n    mod.API = MagicMock()\n    saved = sys.modules.get(\"aria2p\", _SENTINEL := object())\n    sys.modules[\"aria2p\"] = mod\n    yield mod\n    if saved is _SENTINEL:\n        sys.modules.pop(\"aria2p\", None)\n    else:\n        sys.modules[\"aria2p\"] = saved\n\n\n@pytest.fixture()\ndef mock_aria2_success(aria2_env, fake_aria2p):\n    \"\"\"Mock aria2p with a download that completes immediately.\n\n    The mock ``add_uris`` side-effect creates the target file on disk so that\n    the post-download verification in ``_download_file_aria2`` passes.\n    \"\"\"\n    mock_download = Mock()\n    mock_download.total_length = 1024\n    mock_download.completed_length = 1024\n    mock_download.is_complete = True\n    mock_download.has_failed = False\n    mock_download.is_removed = False\n    mock_download.update = Mock()\n\n    mock_api = Mock()\n\n    def _add_uris(_uris, options=None):\n        if options:\n            import pathlib\n\n            pathlib.Path(options[\"dir\"], options[\"out\"]).touch()\n        return mock_download\n\n    mock_api.add_uris.side_effect = _add_uris\n\n    fake_aria2p.API.return_value = mock_api\n    yield {\n        \"api\": mock_api,\n        \"client_cls\": fake_aria2p.Client,\n        \"api_cls\": fake_aria2p.API,\n        \"download\": mock_download,\n    }\n\n\n# ---------------------------------------------------------------------------\n# TestAria2Download — unit tests for _download_file_aria2\n# ---------------------------------------------------------------------------\n\n\nclass TestAria2Download:\n    def test_success(self, tmp_path, mock_aria2_success):\n        \"\"\"Happy path: aria2 download completes successfully.\"\"\"\n        target = tmp_path / \"model.safetensors\"\n        _download_file_aria2(\"http://example.com/model.safetensors\", target)\n\n        mock_aria2_success[\"api\"].add_uris.assert_called_once()\n        call_args = mock_aria2_success[\"api\"].add_uris.call_args\n        assert call_args[0][0] == [\"http://example.com/model.safetensors\"]\n        opts = call_args[1][\"options\"]\n        assert opts[\"dir\"] == str(tmp_path)\n        assert opts[\"out\"] == \"model.safetensors\"\n\n    def test_passes_headers(self, tmp_path, mock_aria2_success):\n        \"\"\"CivitAI auth headers are forwarded as aria2 header option.\"\"\"\n        target = tmp_path / \"model.bin\"\n        headers = {\"Authorization\": \"Bearer tok123\", \"Content-Type\": \"application/json\"}\n        _download_file_aria2(\"http://example.com/model.bin\", target, headers=headers)\n\n        opts = mock_aria2_success[\"api\"].add_uris.call_args[1][\"options\"]\n        assert \"header\" in opts\n        assert \"Authorization: Bearer tok123\" in opts[\"header\"]\n        assert \"Content-Type: application/json\" in opts[\"header\"]\n\n    def test_no_headers(self, tmp_path, mock_aria2_success):\n        \"\"\"When no headers provided, 'header' key is absent from options.\"\"\"\n        target = tmp_path / \"model.bin\"\n        _download_file_aria2(\"http://example.com/model.bin\", target)\n\n        opts = mock_aria2_success[\"api\"].add_uris.call_args[1][\"options\"]\n        assert \"header\" not in opts\n\n    def test_missing_server_env_raises(self, tmp_path, fake_aria2p, monkeypatch):\n        \"\"\"Error when COMFYUI_MANAGER_ARIA2_SERVER is not set.\"\"\"\n        monkeypatch.delenv(constants.ARIA2_SERVER_ENV_KEY, raising=False)\n        monkeypatch.delenv(constants.ARIA2_SECRET_ENV_KEY, raising=False)\n        with pytest.raises(DownloadException, match=constants.ARIA2_SERVER_ENV_KEY):\n            _download_file_aria2(\"http://example.com/f.bin\", tmp_path / \"f.bin\")\n\n    def test_import_error_raises(self, tmp_path, aria2_env):\n        \"\"\"Error when aria2p package is not installed.\"\"\"\n        with patch.dict(sys.modules, {\"aria2p\": None}):\n            with pytest.raises(DownloadException, match=\"aria2p is required\"):\n                _download_file_aria2(\"http://example.com/f.bin\", tmp_path / \"f.bin\")\n\n    def test_download_failure_raises(self, tmp_path, aria2_env, fake_aria2p):\n        \"\"\"Error when aria2 reports download failed.\"\"\"\n        mock_download = Mock()\n        mock_download.total_length = 0\n        mock_download.completed_length = 0\n        mock_download.is_complete = False\n        mock_download.has_failed = True\n        mock_download.is_removed = False\n        mock_download.error_message = \"403 Forbidden\"\n        mock_download.error_code = \"3\"\n        mock_download.update = Mock()\n\n        mock_api = Mock()\n        mock_api.add_uris.return_value = mock_download\n        fake_aria2p.API.return_value = mock_api\n\n        with pytest.raises(DownloadException, match=\"403 Forbidden\"):\n            _download_file_aria2(\"http://example.com/f.bin\", tmp_path / \"f.bin\")\n\n    def test_download_removed_raises(self, tmp_path, aria2_env, fake_aria2p):\n        \"\"\"Error when aria2 download is removed during progress.\"\"\"\n        mock_download = Mock()\n        mock_download.total_length = 0\n        mock_download.completed_length = 0\n        mock_download.is_complete = False\n        mock_download.has_failed = False\n        mock_download.is_removed = True\n        mock_download.update = Mock()\n\n        mock_api = Mock()\n        mock_api.add_uris.return_value = mock_download\n        fake_aria2p.API.return_value = mock_api\n\n        with pytest.raises(DownloadException, match=\"removed\"):\n            _download_file_aria2(\"http://example.com/f.bin\", tmp_path / \"f.bin\")\n\n    def test_server_url_parsing(self, tmp_path, fake_aria2p, monkeypatch):\n        \"\"\"Server URL is correctly parsed into host and port.\"\"\"\n        monkeypatch.setenv(constants.ARIA2_SERVER_ENV_KEY, \"http://myserver:6800\")\n        monkeypatch.setenv(constants.ARIA2_SECRET_ENV_KEY, \"\")\n\n        mock_download = Mock(\n            total_length=100,\n            completed_length=100,\n            is_complete=True,\n            has_failed=False,\n            is_removed=False,\n            update=Mock(),\n        )\n        mock_api = Mock()\n        mock_api.add_uris.return_value = mock_download\n        fake_aria2p.API.return_value = mock_api\n\n        target = tmp_path / \"f.bin\"\n        target.touch()\n        _download_file_aria2(\"http://example.com/f.bin\", target)\n        fake_aria2p.Client.assert_called_once_with(host=\"http://myserver\", port=6800, secret=\"\")\n\n    def test_server_url_default_port(self, tmp_path, fake_aria2p, monkeypatch):\n        \"\"\"Default port 6800 is used when not specified in URL.\"\"\"\n        monkeypatch.setenv(constants.ARIA2_SERVER_ENV_KEY, \"http://myserver\")\n        monkeypatch.setenv(constants.ARIA2_SECRET_ENV_KEY, \"\")\n\n        mock_download = Mock(\n            total_length=100,\n            completed_length=100,\n            is_complete=True,\n            has_failed=False,\n            is_removed=False,\n            update=Mock(),\n        )\n        mock_api = Mock()\n        mock_api.add_uris.return_value = mock_download\n        fake_aria2p.API.return_value = mock_api\n\n        target = tmp_path / \"f.bin\"\n        target.touch()\n        _download_file_aria2(\"http://example.com/f.bin\", target)\n        fake_aria2p.Client.assert_called_once_with(host=\"http://myserver\", port=6800, secret=\"\")\n\n    def test_server_url_without_scheme(self, tmp_path, fake_aria2p, monkeypatch):\n        \"\"\"Server URL without scheme gets http:// prepended.\"\"\"\n        monkeypatch.setenv(constants.ARIA2_SERVER_ENV_KEY, \"myserver:6800\")\n        monkeypatch.setenv(constants.ARIA2_SECRET_ENV_KEY, \"\")\n\n        mock_download = Mock(\n            total_length=100,\n            completed_length=100,\n            is_complete=True,\n            has_failed=False,\n            is_removed=False,\n            update=Mock(),\n        )\n        mock_api = Mock()\n        mock_api.add_uris.return_value = mock_download\n        fake_aria2p.API.return_value = mock_api\n\n        target = tmp_path / \"f.bin\"\n        target.touch()\n        _download_file_aria2(\"http://example.com/f.bin\", target)\n        fake_aria2p.Client.assert_called_once_with(host=\"http://myserver\", port=6800, secret=\"\")\n\n    def test_secret_passed_to_client(self, tmp_path, fake_aria2p, monkeypatch):\n        \"\"\"Secret from env var is passed to aria2p.Client.\"\"\"\n        monkeypatch.setenv(constants.ARIA2_SERVER_ENV_KEY, \"http://localhost:6800\")\n        monkeypatch.setenv(constants.ARIA2_SECRET_ENV_KEY, \"supersecret\")\n\n        mock_download = Mock(\n            total_length=100,\n            completed_length=100,\n            is_complete=True,\n            has_failed=False,\n            is_removed=False,\n            update=Mock(),\n        )\n        mock_api = Mock()\n        mock_api.add_uris.return_value = mock_download\n        fake_aria2p.API.return_value = mock_api\n\n        target = tmp_path / \"f.bin\"\n        target.touch()\n        _download_file_aria2(\"http://example.com/f.bin\", target)\n        fake_aria2p.Client.assert_called_once_with(host=\"http://localhost\", port=6800, secret=\"supersecret\")\n\n    def test_malformed_server_url_raises(self, tmp_path, fake_aria2p, monkeypatch):\n        \"\"\"Malformed server URL with unparseable hostname raises clear error.\"\"\"\n        monkeypatch.setenv(constants.ARIA2_SERVER_ENV_KEY, \"://\")\n        monkeypatch.setenv(constants.ARIA2_SECRET_ENV_KEY, \"\")\n\n        with pytest.raises(DownloadException, match=\"cannot parse hostname\"):\n            _download_file_aria2(\"http://example.com/f.bin\", tmp_path / \"f.bin\")\n\n    def test_update_connection_error_raises(self, tmp_path, aria2_env, fake_aria2p):\n        \"\"\"Connection drop during polling raises DownloadException.\"\"\"\n        mock_download = Mock()\n        mock_download.total_length = 0\n        mock_download.completed_length = 0\n        mock_download.is_complete = False\n        mock_download.has_failed = False\n        mock_download.is_removed = False\n        mock_download.update = Mock(side_effect=ConnectionError(\"RPC server gone\"))\n\n        mock_api = Mock()\n        mock_api.add_uris.return_value = mock_download\n        fake_aria2p.API.return_value = mock_api\n\n        with pytest.raises(DownloadException, match=\"Lost connection to aria2\"):\n            _download_file_aria2(\"http://example.com/f.bin\", tmp_path / \"f.bin\")\n\n    def test_file_missing_after_download_raises(self, tmp_path, aria2_env, fake_aria2p):\n        \"\"\"Error when aria2 reports success but file is not on disk.\"\"\"\n        target = tmp_path / \"subdir\" / \"model.safetensors\"\n\n        mock_download = Mock(\n            total_length=100,\n            completed_length=100,\n            is_complete=True,\n            has_failed=False,\n            is_removed=False,\n            update=Mock(),\n        )\n        mock_api = Mock()\n        mock_api.add_uris.return_value = mock_download\n        fake_aria2p.API.return_value = mock_api\n\n        with pytest.raises(DownloadException, match=\"file not found at expected path\"):\n            _download_file_aria2(\"http://example.com/model.safetensors\", target)\n\n\n# ---------------------------------------------------------------------------\n# TestDownloadFileDispatch — dispatch tests for download_file\n# ---------------------------------------------------------------------------\n\n\nclass TestDownloadFileDispatch:\n    def test_default_downloader_uses_httpx(self, tmp_path):\n        \"\"\"When downloader is not specified, httpx is used.\"\"\"\n        mock_response = Mock()\n        mock_response.status_code = 200\n        mock_response.headers = {\"Content-Length\": \"4\"}\n        mock_response.iter_bytes.return_value = [b\"data\"]\n        mock_response.__enter__ = Mock(return_value=mock_response)\n        mock_response.__exit__ = Mock(return_value=None)\n\n        with patch(\"httpx.stream\", return_value=mock_response) as mock_stream:\n            download_file(\"http://example.com/f.bin\", tmp_path / \"f.bin\")\n            mock_stream.assert_called_once()\n\n    def test_downloader_httpx_explicit(self, tmp_path):\n        \"\"\"When downloader='httpx', httpx is used.\"\"\"\n        mock_response = Mock()\n        mock_response.status_code = 200\n        mock_response.headers = {\"Content-Length\": \"4\"}\n        mock_response.iter_bytes.return_value = [b\"data\"]\n        mock_response.__enter__ = Mock(return_value=mock_response)\n        mock_response.__exit__ = Mock(return_value=None)\n\n        with patch(\"httpx.stream\", return_value=mock_response) as mock_stream:\n            download_file(\"http://example.com/f.bin\", tmp_path / \"f.bin\", downloader=\"httpx\")\n            mock_stream.assert_called_once()\n\n    def test_downloader_aria2_dispatches(self, tmp_path):\n        \"\"\"When downloader='aria2', aria2 backend is used.\"\"\"\n        with patch(\"comfy_cli.file_utils._download_file_aria2\") as mock_aria2:\n            download_file(\"http://example.com/f.bin\", tmp_path / \"f.bin\", downloader=\"aria2\")\n            mock_aria2.assert_called_once_with(\"http://example.com/f.bin\", tmp_path / \"f.bin\", None)\n\n    def test_invalid_downloader_raises(self, tmp_path):\n        \"\"\"Invalid downloader value raises DownloadException.\"\"\"\n        with pytest.raises(DownloadException, match=\"Unknown downloader\"):\n            download_file(\"http://example.com/f.bin\", tmp_path / \"f.bin\", downloader=\"foobar\")\n"
  },
  {
    "path": "tests/comfy_cli/test_cm_cli_python_resolution.py",
    "content": "import subprocess\nimport sys\nimport textwrap\nimport time\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\n\nfrom comfy_cli.command.custom_nodes import cm_cli_util\n\n\ndef _setup_cm_cli(tmp_path, script_body):\n    \"\"\"Create a stub script and return its path.\"\"\"\n    stub_script = tmp_path / \"stub_cm_cli.py\"\n    stub_script.write_text(textwrap.dedent(script_body))\n    (tmp_path / \"config\").mkdir(exist_ok=True)\n    return stub_script\n\n\ndef _run(tmp_path, args, *, fast_deps=False, raise_on_error=False):\n    \"\"\"Call execute_cm_cli with standard patches for workspace/config.\n\n    Patches the cmd construction to run the stub script instead of `python -m cm_cli`.\n    \"\"\"\n    stub_script = tmp_path / \"stub_cm_cli.py\"\n    original_popen = subprocess.Popen\n\n    def _patched_popen(cmd, **kwargs):\n        # Replace `python -m cm_cli <args>` with `python <stub_script> <args>`\n        if len(cmd) >= 3 and cmd[1] == \"-m\" and cmd[2] == \"cm_cli\":\n            cmd = [cmd[0], str(stub_script)] + cmd[3:]\n        return original_popen(cmd, **kwargs)\n\n    with (\n        patch(\n            \"comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python\",\n            return_value=sys.executable,\n        ),\n        patch.object(cm_cli_util.workspace_manager, \"workspace_path\", str(tmp_path)),\n        patch.object(cm_cli_util.workspace_manager, \"set_recent_workspace\"),\n        patch(\"comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager\") as MockConfig,\n        patch(\"comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo\", return_value=(True, None)),\n        patch(\"comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli\", return_value=True),\n        patch(\"comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen\", side_effect=_patched_popen),\n    ):\n        MockConfig.return_value.get_config_path.return_value = str(tmp_path / \"config\")\n        return cm_cli_util.execute_cm_cli(args, fast_deps=fast_deps, raise_on_error=raise_on_error)\n\n\nclass TestExecuteCmCli:\n    def test_uses_resolved_python(self, tmp_path):\n        _setup_cm_cli(tmp_path, 'print(\"ok\")')\n        mock_proc = MagicMock()\n        mock_proc.stdout = iter([\"ok\\n\"])\n        mock_proc.wait.return_value = 0\n\n        with (\n            patch(\n                \"comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python\",\n                return_value=\"/resolved/python\",\n            ) as mock_resolve,\n            patch.object(cm_cli_util.workspace_manager, \"workspace_path\", str(tmp_path)),\n            patch.object(cm_cli_util.workspace_manager, \"set_recent_workspace\"),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager\") as MockConfig,\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo\", return_value=(True, None)),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli\", return_value=True),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen\", return_value=mock_proc) as mock_popen,\n        ):\n            MockConfig.return_value.get_config_path.return_value = str(tmp_path / \"config\")\n            cm_cli_util.execute_cm_cli([\"show\", \"installed\"])\n\n        mock_resolve.assert_called_once_with(str(tmp_path))\n        cmd = mock_popen.call_args[0][0]\n        assert cmd[0] == \"/resolved/python\"\n\n    def test_fast_deps_passes_python_to_compiler(self, tmp_path):\n        _setup_cm_cli(tmp_path, 'print(\"ok\")')\n        mock_proc = MagicMock()\n        mock_proc.stdout = iter([\"ok\\n\"])\n        mock_proc.wait.return_value = 0\n\n        with (\n            patch(\n                \"comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python\",\n                return_value=\"/resolved/python\",\n            ),\n            patch.object(cm_cli_util.workspace_manager, \"workspace_path\", str(tmp_path)),\n            patch.object(cm_cli_util.workspace_manager, \"set_recent_workspace\"),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager\") as MockConfig,\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo\", return_value=(True, None)),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli\", return_value=True),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen\", return_value=mock_proc),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.DependencyCompiler\") as MockCompiler,\n        ):\n            MockConfig.return_value.get_config_path.return_value = str(tmp_path / \"config\")\n            mock_instance = MagicMock()\n            MockCompiler.return_value = mock_instance\n\n            cm_cli_util.execute_cm_cli([\"install\", \"some-node\"], fast_deps=True)\n\n        MockCompiler.assert_called_once()\n        assert MockCompiler.call_args[1][\"executable\"] == \"/resolved/python\"\n\n    def test_stdout_returned_and_streamed(self, tmp_path, capsys):\n        _setup_cm_cli(\n            tmp_path,\n            \"\"\"\\\n            print(\"line 1\")\n            print(\"line 2\")\n            print(\"line 3\")\n        \"\"\",\n        )\n        result = _run(tmp_path, [\"test\"])\n\n        assert result == \"line 1\\nline 2\\nline 3\\n\"\n        captured = capsys.readouterr()\n        assert \"line 1\\nline 2\\nline 3\\n\" in captured.out\n\n    @pytest.mark.parametrize(\"returncode\", [1, 2])\n    def test_expected_error_codes_return_none(self, tmp_path, returncode):\n        _setup_cm_cli(\n            tmp_path,\n            f\"\"\"\\\n            import sys\n            sys.exit({returncode})\n        \"\"\",\n        )\n        result = _run(tmp_path, [\"test\"])\n        assert result is None\n\n    def test_unexpected_error_code_raises(self, tmp_path):\n        _setup_cm_cli(\n            tmp_path,\n            \"\"\"\\\n            import sys\n            sys.exit(42)\n        \"\"\",\n        )\n        with pytest.raises(subprocess.CalledProcessError) as exc_info:\n            _run(tmp_path, [\"test\"])\n        assert exc_info.value.returncode == 42\n\n    def test_raise_on_error_overrides_silent_return(self, tmp_path):\n        _setup_cm_cli(\n            tmp_path,\n            \"\"\"\\\n            import sys\n            print(\"output before fail\")\n            sys.exit(1)\n        \"\"\",\n        )\n        with pytest.raises(subprocess.CalledProcessError) as exc_info:\n            _run(tmp_path, [\"test\"], raise_on_error=True)\n        assert exc_info.value.returncode == 1\n        assert \"output before fail\" in exc_info.value.output\n\n    def test_output_streams_incrementally(self, tmp_path):\n        _setup_cm_cli(\n            tmp_path,\n            \"\"\"\\\n            import time\n            for i in range(3):\n                print(f\"line {i}\")\n                time.sleep(0.3)\n        \"\"\",\n        )\n        timestamps = []\n        original_write = sys.stdout.write\n\n        def recording_write(s):\n            if s.startswith(\"line \"):\n                timestamps.append(time.monotonic())\n            return original_write(s)\n\n        with patch(\"sys.stdout\") as mock_stdout:\n            mock_stdout.write = recording_write\n            mock_stdout.flush = lambda: None\n            _run(tmp_path, [\"test\"])\n\n        assert len(timestamps) == 3\n        assert timestamps[2] - timestamps[0] >= 0.4\n\n    def test_pythonunbuffered_set_in_env(self, tmp_path):\n        _setup_cm_cli(tmp_path, 'print(\"ok\")')\n        mock_proc = MagicMock()\n        mock_proc.stdout = iter([\"ok\\n\"])\n        mock_proc.wait.return_value = 0\n\n        with (\n            patch(\n                \"comfy_cli.command.custom_nodes.cm_cli_util.resolve_workspace_python\",\n                return_value=sys.executable,\n            ),\n            patch.object(cm_cli_util.workspace_manager, \"workspace_path\", str(tmp_path)),\n            patch.object(cm_cli_util.workspace_manager, \"set_recent_workspace\"),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.ConfigManager\") as MockConfig,\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.check_comfy_repo\", return_value=(True, None)),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli\", return_value=True),\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.subprocess.Popen\", return_value=mock_proc) as mock_popen,\n        ):\n            MockConfig.return_value.get_config_path.return_value = str(tmp_path / \"config\")\n            cm_cli_util.execute_cm_cli([\"show\", \"installed\"])\n\n        env = mock_popen.call_args[1][\"env\"]\n        assert env[\"PYTHONUNBUFFERED\"] == \"1\"\n"
  },
  {
    "path": "tests/comfy_cli/test_cmdline_python_resolution.py",
    "content": "from unittest.mock import MagicMock, patch\n\nfrom comfy_cli import cmdline\n\n\nclass TestUpdateComfy:\n    def test_uses_resolved_python(self, tmp_path):\n        with (\n            patch(\"comfy_cli.cmdline.resolve_workspace_python\", return_value=\"/resolved/python\") as mock_resolve,\n            patch.object(cmdline.workspace_manager, \"workspace_path\", str(tmp_path)),\n            patch(\"comfy_cli.cmdline.os.chdir\"),\n            patch(\"comfy_cli.cmdline.subprocess.run\") as mock_run,\n            patch(\"comfy_cli.cmdline.custom_nodes.command.update_node_id_cache\"),\n        ):\n            cmdline.update(target=\"comfy\")\n\n        mock_resolve.assert_called_once_with(str(tmp_path))\n        pip_call = None\n        for c in mock_run.call_args_list:\n            cmd = c[0][0]\n            if \"-m\" in cmd and \"pip\" in cmd:\n                pip_call = cmd\n                break\n\n        assert pip_call is not None, \"pip install call not found\"\n        assert pip_call[0] == \"/resolved/python\"\n\n    def test_update_comfy_succeeds_when_cm_cli_missing(self, tmp_path):\n        \"\"\"Regression test for #403: comfy update must not crash when cm-cli is absent.\"\"\"\n        with (\n            patch(\"comfy_cli.cmdline.resolve_workspace_python\", return_value=\"/resolved/python\"),\n            patch.object(cmdline.workspace_manager, \"workspace_path\", str(tmp_path)),\n            patch(\"comfy_cli.cmdline.os.chdir\"),\n            patch(\"comfy_cli.cmdline.subprocess.run\"),\n            patch(\n                \"comfy_cli.cmdline.custom_nodes.command.update_node_id_cache\",\n                side_effect=FileNotFoundError(\"cm-cli not found\"),\n            ) as mock_cache,\n        ):\n            cmdline.update(target=\"comfy\")\n        mock_cache.assert_called_once()\n\n\nclass TestDependency:\n    def test_passes_python_to_compiler(self, tmp_path):\n        with (\n            patch(\"comfy_cli.cmdline.resolve_workspace_python\", return_value=\"/resolved/python\") as mock_resolve,\n            patch.object(cmdline.workspace_manager, \"get_workspace_path\", return_value=(str(tmp_path), None)),\n            patch(\"comfy_cli.cmdline.DependencyCompiler\") as MockCompiler,\n        ):\n            mock_instance = MagicMock()\n            MockCompiler.return_value = mock_instance\n\n            cmdline.dependency()\n\n        mock_resolve.assert_called_once_with(str(tmp_path))\n        MockCompiler.assert_called_once()\n        assert MockCompiler.call_args[1][\"executable\"] == \"/resolved/python\"\n"
  },
  {
    "path": "tests/comfy_cli/test_config_manager.py",
    "content": "import configparser\nimport os\nfrom unittest.mock import patch\n\nimport pytest\n\nfrom comfy_cli import constants\nfrom comfy_cli.config_manager import ConfigManager\n\n# Unwrap the singleton to access the original class for testing.\n# The singleton decorator stores the original class as the 'cls'\n# free variable in the wrapper closure.\n_ConfigManagerCls = ConfigManager.__closure__[0].cell_contents\n\n\ndef _make_config_manager(config_dir, is_running_val=True):\n    with (\n        patch.object(_ConfigManagerCls, \"get_config_path\", return_value=str(config_dir)),\n        patch(\"comfy_cli.config_manager.is_running\", return_value=is_running_val),\n    ):\n        return _ConfigManagerCls()\n\n\n@pytest.fixture\ndef config_mgr(tmp_path):\n    config_dir = tmp_path / \"comfy-cli\"\n    config_dir.mkdir()\n    yield _make_config_manager(config_dir)\n\n\nclass TestLoad:\n    def test_creates_tmp_directory(self, tmp_path):\n        config_dir = tmp_path / \"comfy-cli\"\n        config_dir.mkdir()\n        _make_config_manager(config_dir)\n        assert (config_dir / \"tmp\").is_dir()\n\n    def test_reads_existing_config(self, tmp_path):\n        config_dir = tmp_path / \"comfy-cli\"\n        config_dir.mkdir()\n        (config_dir / \"config.ini\").write_text(\n            f\"[DEFAULT]\\n{constants.CONFIG_KEY_DEFAULT_WORKSPACE} = /path/to/comfy\\n\"\n        )\n        mgr = _make_config_manager(config_dir)\n        assert mgr.get(constants.CONFIG_KEY_DEFAULT_WORKSPACE) == \"/path/to/comfy\"\n\n    def test_parses_background_info(self, tmp_path):\n        config_dir = tmp_path / \"comfy-cli\"\n        config_dir.mkdir()\n        (config_dir / \"config.ini\").write_text(\n            f\"[DEFAULT]\\n{constants.CONFIG_KEY_BACKGROUND} = ('localhost', 8188, 12345)\\n\"\n        )\n        mgr = _make_config_manager(config_dir, is_running_val=True)\n        assert mgr.background == (\"localhost\", 8188, 12345)\n\n    def test_removes_background_when_stale_pid(self, tmp_path):\n        config_dir = tmp_path / \"comfy-cli\"\n        config_dir.mkdir()\n        (config_dir / \"config.ini\").write_text(\n            f\"[DEFAULT]\\n{constants.CONFIG_KEY_BACKGROUND} = ('localhost', 8188, 99999)\\n\"\n        )\n        mgr = _make_config_manager(config_dir, is_running_val=False)\n        assert mgr.background is None\n        assert constants.CONFIG_KEY_BACKGROUND not in mgr.config[\"DEFAULT\"]\n\n\nclass TestWriteConfig:\n    def test_creates_directory_if_missing(self, tmp_path):\n        config_dir = tmp_path / \"new-dir\"\n        with patch.object(_ConfigManagerCls, \"get_config_path\", return_value=str(config_dir)):\n            mgr = _ConfigManagerCls.__new__(_ConfigManagerCls)\n            mgr.config = configparser.ConfigParser()\n            mgr.background = None\n            mgr.write_config()\n        assert (config_dir / \"config.ini\").exists()\n\n    def test_set_persists_to_file(self, config_mgr):\n        config_mgr.set(\"my_key\", \"my_value\")\n        parser = configparser.ConfigParser()\n        parser.read(config_mgr.get_config_file_path())\n        assert parser[\"DEFAULT\"][\"my_key\"] == \"my_value\"\n\n\nclass TestGetBool:\n    def test_missing_key_returns_none(self, config_mgr):\n        assert config_mgr.get_bool(\"nonexistent\") is None\n\n\nclass TestGetOrOverride:\n    def test_set_value_wins(self, config_mgr):\n        config_mgr.config[\"DEFAULT\"][\"k\"] = \"from_config\"\n        with patch.dict(os.environ, {\"EK\": \"from_env\"}):\n            assert config_mgr.get_or_override(\"EK\", \"k\", set_value=\"from_cli\") == \"from_cli\"\n\n    def test_env_var_wins_over_config(self, config_mgr):\n        config_mgr.config[\"DEFAULT\"][\"k\"] = \"from_config\"\n        with patch.dict(os.environ, {\"EK\": \"from_env\"}):\n            assert config_mgr.get_or_override(\"EK\", \"k\") == \"from_env\"\n\n    def test_config_is_fallback(self, config_mgr):\n        config_mgr.config[\"DEFAULT\"][\"k\"] = \"from_config\"\n        env = os.environ.copy()\n        env.pop(\"EK\", None)\n        with patch.dict(os.environ, env, clear=True):\n            assert config_mgr.get_or_override(\"EK\", \"k\") == \"from_config\"\n\n    def test_empty_set_value_returns_none(self, config_mgr):\n        assert config_mgr.get_or_override(\"EK\", \"k\", set_value=\"\") is None\n\n    def test_empty_env_var_returns_none(self, config_mgr):\n        with patch.dict(os.environ, {\"EK\": \"\"}):\n            assert config_mgr.get_or_override(\"EK\", \"k\") is None\n\n    def test_set_value_is_persisted(self, config_mgr):\n        config_mgr.get_or_override(\"EK\", \"k\", set_value=\"saved\")\n        assert config_mgr.get(\"k\") == \"saved\"\n\n    def test_all_missing_returns_none(self, config_mgr):\n        env = os.environ.copy()\n        env.pop(\"EK\", None)\n        with patch.dict(os.environ, env, clear=True):\n            assert config_mgr.get_or_override(\"EK\", \"k\") is None\n\n\nclass TestGetEnvData:\n    def test_full_config(self, config_mgr):\n        config_mgr.config[\"DEFAULT\"][constants.CONFIG_KEY_DEFAULT_WORKSPACE] = \"/my/ws\"\n        config_mgr.config[\"DEFAULT\"][constants.CONFIG_KEY_DEFAULT_LAUNCH_EXTRAS] = \"--cpu\"\n        config_mgr.config[\"DEFAULT\"][constants.CONFIG_KEY_RECENT_WORKSPACE] = \"/recent\"\n        config_mgr.config[\"DEFAULT\"][constants.CONFIG_KEY_ENABLE_TRACKING] = \"true\"\n        config_mgr.config[\"DEFAULT\"][constants.CONFIG_KEY_BACKGROUND] = \"('localhost', 8188, 42)\"\n        config_mgr.background = (\"localhost\", 8188, 42)\n\n        data = dict(config_mgr.get_env_data())\n        assert data[\"Default ComfyUI workspace\"] == \"/my/ws\"\n        assert data[\"Default ComfyUI launch extra options\"] == \"--cpu\"\n        assert data[\"Recent ComfyUI workspace\"] == \"/recent\"\n        assert data[\"Tracking Analytics\"] == \"Enabled\"\n        assert \"localhost:8188\" in data[\"Background ComfyUI\"]\n        assert \"42\" in data[\"Background ComfyUI\"]\n\n    def test_empty_config(self, config_mgr):\n        data = dict(config_mgr.get_env_data())\n        assert data[\"Default ComfyUI workspace\"] == \"No default ComfyUI workspace\"\n        assert data[\"Recent ComfyUI workspace\"] == \"No recent run\"\n        assert \"Tracking Analytics\" not in data\n        assert \"None\" in data[\"Default ComfyUI launch extra options\"]\n\n    def test_launch_extras_only_read_when_workspace_set(self, config_mgr):\n        config_mgr.config[\"DEFAULT\"][constants.CONFIG_KEY_DEFAULT_LAUNCH_EXTRAS] = \"--gpu\"\n        data = dict(config_mgr.get_env_data())\n        assert \"None\" in data[\"Default ComfyUI launch extra options\"]\n\n\nclass TestRemoveBackground:\n    def test_clears_background(self, config_mgr):\n        config_mgr.config[\"DEFAULT\"][constants.CONFIG_KEY_BACKGROUND] = \"('h', 1, 2)\"\n        config_mgr.background = (\"h\", 1, 2)\n        config_mgr.remove_background()\n        assert config_mgr.background is None\n        assert constants.CONFIG_KEY_BACKGROUND not in config_mgr.config[\"DEFAULT\"]\n"
  },
  {
    "path": "tests/comfy_cli/test_cuda_detect.py",
    "content": "import subprocess\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\n\nfrom comfy_cli.cuda_detect import (\n    DEFAULT_CUDA_TAG,\n    PYTORCH_CUDA_WHEELS,\n    _detect_via_ctypes,\n    _detect_via_nvidia_smi,\n    _load_libcuda,\n    detect_cuda_driver_version,\n    resolve_cuda_wheel,\n)\n\n\nclass TestDetectViaCtypes:\n    def test_happy_path(self):\n        lib = MagicMock()\n        lib.cuInit.return_value = 0\n\n        def fake_get(ptr):\n            ptr._obj.value = 13000\n            return 0\n\n        lib.cuDriverGetVersion.side_effect = fake_get\n\n        with patch(\"comfy_cli.cuda_detect._load_libcuda\", return_value=lib):\n            assert _detect_via_ctypes() == 13000\n\n    @pytest.mark.parametrize(\n        \"raw,expected\",\n        [\n            (12060, (12, 6)),\n            (11080, (11, 8)),\n            (13010, (13, 1)),\n            (13000, (13, 0)),\n            (12080, (12, 8)),\n        ],\n    )\n    def test_version_decoding(self, raw, expected):\n        lib = MagicMock()\n        lib.cuInit.return_value = 0\n\n        def fake_get(ptr):\n            ptr._obj.value = raw\n            return 0\n\n        lib.cuDriverGetVersion.side_effect = fake_get\n\n        with patch(\"comfy_cli.cuda_detect._load_libcuda\", return_value=lib):\n            result = _detect_via_ctypes()\n        assert result == raw\n        major = result // 1000\n        minor = (result % 1000) // 10\n        assert (major, minor) == expected\n\n    def test_library_not_found(self):\n        with patch(\"comfy_cli.cuda_detect._load_libcuda\", side_effect=OSError(\"not found\")):\n            assert _detect_via_ctypes() is None\n\n    def test_cuinit_fails(self):\n        lib = MagicMock()\n        lib.cuInit.return_value = 100\n\n        with patch(\"comfy_cli.cuda_detect._load_libcuda\", return_value=lib):\n            assert _detect_via_ctypes() is None\n\n\nclass TestDetectViaNvidiaSmi:\n    def test_happy_path(self):\n        output = (\n            \"Mon Mar 30 12:00:00 2026\\n\"\n            \"+-------------------------+\\n\"\n            \"| NVIDIA-SMI 560.35.03    Driver Version: 560.35.03    CUDA Version: 12.6  |\\n\"\n        )\n        with patch(\"comfy_cli.cuda_detect.subprocess.check_output\", return_value=output):\n            assert _detect_via_nvidia_smi() == (12, 6)\n\n    def test_cuda_13(self):\n        output = \"| NVIDIA-SMI 570.00    Driver Version: 570.00    CUDA Version: 13.0  |\\n\"\n        with patch(\"comfy_cli.cuda_detect.subprocess.check_output\", return_value=output):\n            assert _detect_via_nvidia_smi() == (13, 0)\n\n    def test_not_found(self):\n        with patch(\"comfy_cli.cuda_detect.subprocess.check_output\", side_effect=FileNotFoundError):\n            assert _detect_via_nvidia_smi() is None\n\n    def test_parse_failure(self):\n        with patch(\"comfy_cli.cuda_detect.subprocess.check_output\", return_value=\"some random output\"):\n            assert _detect_via_nvidia_smi() is None\n\n    def test_timeout(self):\n        with patch(\n            \"comfy_cli.cuda_detect.subprocess.check_output\",\n            side_effect=subprocess.TimeoutExpired(\"nvidia-smi\", 10),\n        ):\n            assert _detect_via_nvidia_smi() is None\n\n\nclass TestDetectCudaDriverVersion:\n    def test_ctypes_success_skips_smi(self):\n        lib = MagicMock()\n        lib.cuInit.return_value = 0\n\n        def fake_get(ptr):\n            ptr._obj.value = 12060\n            return 0\n\n        lib.cuDriverGetVersion.side_effect = fake_get\n\n        with (\n            patch(\"comfy_cli.cuda_detect._load_libcuda\", return_value=lib),\n            patch(\"comfy_cli.cuda_detect._detect_via_nvidia_smi\") as mock_smi,\n        ):\n            result = detect_cuda_driver_version()\n            assert result == (12, 6)\n            mock_smi.assert_not_called()\n\n    def test_ctypes_fails_falls_back_to_smi(self):\n        with (\n            patch(\"comfy_cli.cuda_detect._load_libcuda\", side_effect=OSError),\n            patch(\"comfy_cli.cuda_detect._detect_via_nvidia_smi\", return_value=(13, 0)),\n        ):\n            assert detect_cuda_driver_version() == (13, 0)\n\n    def test_both_fail(self):\n        with (\n            patch(\"comfy_cli.cuda_detect._load_libcuda\", side_effect=OSError),\n            patch(\"comfy_cli.cuda_detect._detect_via_nvidia_smi\", return_value=None),\n        ):\n            assert detect_cuda_driver_version() is None\n\n    def test_cuda_visible_devices_restored(self):\n        import os\n\n        with (\n            patch.dict(os.environ, {\"CUDA_VISIBLE_DEVICES\": \"0,1\"}),\n            patch(\"comfy_cli.cuda_detect._load_libcuda\", side_effect=OSError),\n            patch(\"comfy_cli.cuda_detect._detect_via_nvidia_smi\", return_value=None),\n        ):\n            detect_cuda_driver_version()\n            assert os.environ[\"CUDA_VISIBLE_DEVICES\"] == \"0,1\"\n\n    def test_cuda_visible_devices_empty_string(self):\n        import os\n\n        lib = MagicMock()\n        env_during_call = {}\n\n        def capturing_cuInit(val):\n            env_during_call[\"CUDA_VISIBLE_DEVICES\"] = os.environ.get(\"CUDA_VISIBLE_DEVICES\", \"UNSET\")\n            return 0\n\n        lib.cuInit.side_effect = capturing_cuInit\n\n        def fake_get(ptr):\n            ptr._obj.value = 13000\n            return 0\n\n        lib.cuDriverGetVersion.side_effect = fake_get\n\n        with (\n            patch.dict(os.environ, {\"CUDA_VISIBLE_DEVICES\": \"\"}),\n            patch(\"comfy_cli.cuda_detect._load_libcuda\", return_value=lib),\n        ):\n            result = detect_cuda_driver_version()\n            assert result == (13, 0)\n            assert env_during_call[\"CUDA_VISIBLE_DEVICES\"] == \"UNSET\"\n            assert os.environ[\"CUDA_VISIBLE_DEVICES\"] == \"\"\n\n\nclass TestResolveCudaWheel:\n    @pytest.mark.parametrize(\n        \"driver_version,expected\",\n        [\n            ((13, 0), \"cu130\"),\n            ((12, 9), \"cu129\"),\n            ((12, 8), \"cu128\"),\n            ((12, 7), \"cu126\"),\n            ((12, 6), \"cu126\"),\n            ((12, 5), \"cu124\"),\n            ((12, 4), \"cu124\"),\n            ((12, 1), \"cu121\"),\n            ((12, 0), \"cu118\"),\n            ((11, 8), \"cu118\"),\n        ],\n    )\n    def test_mapping(self, driver_version, expected):\n        assert resolve_cuda_wheel(driver_version) == expected\n\n    def test_driver_too_old(self):\n        assert resolve_cuda_wheel((11, 7)) is None\n        assert resolve_cuda_wheel((10, 0)) is None\n\n    def test_very_new_driver(self):\n        assert resolve_cuda_wheel((14, 0)) == \"cu130\"\n        assert resolve_cuda_wheel((15, 5)) == \"cu130\"\n\n    def test_exact_match_preferred(self):\n        assert resolve_cuda_wheel((13, 0)) == \"cu130\"\n        assert resolve_cuda_wheel((12, 6)) == \"cu126\"\n\n\nclass TestLoadLibcuda:\n    def test_linux_paths_tried_in_order(self):\n        calls = []\n\n        def tracking_cdll(path):\n            calls.append(path)\n            raise OSError(\"not found\")\n\n        with (\n            patch(\"comfy_cli.cuda_detect.platform.system\", return_value=\"Linux\"),\n            patch(\"comfy_cli.cuda_detect.ctypes.CDLL\", side_effect=tracking_cdll),\n            pytest.raises(OSError),\n        ):\n            _load_libcuda()\n\n        assert calls == [\n            \"libcuda.so.1\",\n            \"/usr/lib/wsl/lib/libcuda.so.1\",\n            \"/usr/lib64/nvidia/libcuda.so.1\",\n            \"/usr/lib/x86_64-linux-gnu/libcuda.so.1\",\n        ]\n\n    def test_windows_path(self):\n        calls = []\n\n        def tracking_cdll(path):\n            calls.append(path)\n            raise OSError(\"not found\")\n\n        with (\n            patch(\"comfy_cli.cuda_detect.platform.system\", return_value=\"Windows\"),\n            patch(\"comfy_cli.cuda_detect.ctypes.CDLL\", side_effect=tracking_cdll),\n            pytest.raises(OSError),\n        ):\n            _load_libcuda()\n\n        assert calls == [\"nvcuda.dll\"]\n\n    def test_first_success_wins(self):\n        mock_lib = MagicMock()\n\n        def first_success(path):\n            if path == \"libcuda.so.1\":\n                return mock_lib\n            raise OSError(\"not found\")\n\n        with (\n            patch(\"comfy_cli.cuda_detect.platform.system\", return_value=\"Linux\"),\n            patch(\"comfy_cli.cuda_detect.ctypes.CDLL\", side_effect=first_success),\n        ):\n            result = _load_libcuda()\n            assert result is mock_lib\n\n\nclass TestConstants:\n    def test_wheels_in_descending_order(self):\n        def parse_tag(tag):\n            digits = tag[2:]\n            return int(digits[0:2]), int(digits[2:])\n\n        versions = [parse_tag(t) for t in PYTORCH_CUDA_WHEELS]\n        assert versions == sorted(versions, reverse=True)\n\n    def test_default_tag_is_in_wheel_list(self):\n        assert DEFAULT_CUDA_TAG in PYTORCH_CUDA_WHEELS\n"
  },
  {
    "path": "tests/comfy_cli/test_cuda_detect_real.py",
    "content": "\"\"\"Real-hardware integration tests for CUDA auto-detection.\n\nThese tests call the detection functions without mocks, exercising the\nactual ctypes/nvidia-smi code paths on machines with NVIDIA drivers.\n\nAutomatically skipped when nvidia-smi is not available (i.e. no NVIDIA GPU).\nRuns on GPU CI runners (run-on-gpu.yml) and any dev machine with a GPU.\n\"\"\"\n\nimport shutil\nimport subprocess\n\nimport pytest\n\nfrom comfy_cli.cuda_detect import (\n    PYTORCH_CUDA_WHEELS,\n    _detect_via_nvidia_smi,\n    detect_cuda_driver_version,\n    resolve_cuda_wheel,\n)\n\n_has_nvidia_smi = shutil.which(\"nvidia-smi\") is not None\n\npytestmark = pytest.mark.skipif(\n    not _has_nvidia_smi,\n    reason=\"nvidia-smi not found — no NVIDIA GPU available\",\n)\n\n\ndef _nvidia_smi_cuda_version() -> tuple[int, int] | None:\n    \"\"\"Parse CUDA version directly from nvidia-smi for cross-checking.\"\"\"\n    try:\n        out = subprocess.check_output([\"nvidia-smi\"], text=True, timeout=10, stderr=subprocess.DEVNULL)\n    except (FileNotFoundError, subprocess.SubprocessError):\n        return None\n    import re\n\n    m = re.search(r\"CUDA Version:\\s*(\\d+)\\.(\\d+)\", out)\n    return (int(m.group(1)), int(m.group(2))) if m else None\n\n\nclass TestRealDetection:\n    def test_detect_returns_valid_tuple(self):\n        result = detect_cuda_driver_version()\n        assert result is not None, \"detect_cuda_driver_version() returned None on a machine with nvidia-smi\"\n        major, minor = result\n        assert isinstance(major, int)\n        assert isinstance(minor, int)\n        assert major >= 11, f\"Unexpected CUDA major version: {major}\"\n\n    def test_detect_matches_nvidia_smi(self):\n        smi_version = _nvidia_smi_cuda_version()\n        assert smi_version is not None\n\n        detected = detect_cuda_driver_version()\n        assert detected is not None\n        assert detected == smi_version, (\n            f\"detect_cuda_driver_version() returned {detected} but nvidia-smi reports {smi_version}\"\n        )\n\n    def test_nvidia_smi_fallback_works(self):\n        result = _detect_via_nvidia_smi()\n        assert result is not None, \"_detect_via_nvidia_smi() returned None despite nvidia-smi being available\"\n        major, minor = result\n        assert major >= 11\n\n    def test_resolve_wheel_for_detected_driver(self):\n        detected = detect_cuda_driver_version()\n        assert detected is not None\n\n        tag = resolve_cuda_wheel(detected)\n        assert tag is not None, f\"resolve_cuda_wheel({detected}) returned None — driver too old for any wheel?\"\n        assert tag in PYTORCH_CUDA_WHEELS\n\n    def test_resolved_wheel_version_not_greater_than_driver(self):\n        detected = detect_cuda_driver_version()\n        assert detected is not None\n        drv_major, drv_minor = detected\n\n        tag = resolve_cuda_wheel(detected)\n        assert tag is not None\n\n        digits = tag[2:]\n        whl_major = int(digits[0:2])\n        whl_minor = int(digits[2:])\n        assert (whl_major, whl_minor) <= (drv_major, drv_minor), (\n            f\"Wheel {tag} requires CUDA {whl_major}.{whl_minor} but driver only supports {drv_major}.{drv_minor}\"\n        )\n"
  },
  {
    "path": "tests/comfy_cli/test_custom_nodes_python_resolution.py",
    "content": "import os\nfrom unittest.mock import patch\n\nfrom comfy_cli.command.custom_nodes import command\n\n\nclass TestGetInstalledPackages:\n    def test_uses_resolved_python(self):\n        command.pip_map = None\n\n        with (\n            patch(\n                \"comfy_cli.command.custom_nodes.command.resolve_workspace_python\",\n                return_value=\"/resolved/python\",\n            ),\n            patch.object(command.workspace_manager, \"workspace_path\", \"/fake/workspace\"),\n            patch(\n                \"comfy_cli.command.custom_nodes.command.subprocess.check_output\",\n                return_value=\"Package  Version\\n------  -------\\npip  24.0\\n\",\n            ) as mock_check_output,\n        ):\n            command.get_installed_packages()\n\n        cmd = mock_check_output.call_args[0][0]\n        assert cmd[0] == \"/resolved/python\"\n        assert cmd == [\"/resolved/python\", \"-m\", \"pip\", \"list\"]\n\n        command.pip_map = None\n\n\nclass TestExecuteInstallScript:\n    def test_pip_uses_resolved_python(self, tmp_path):\n        (tmp_path / \"requirements.txt\").write_text(\"somepackage\\n\")\n\n        with (\n            patch(\n                \"comfy_cli.command.custom_nodes.command.resolve_workspace_python\",\n                return_value=\"/resolved/python\",\n            ),\n            patch.object(command.workspace_manager, \"workspace_path\", str(tmp_path)),\n            patch(\"comfy_cli.command.custom_nodes.command.subprocess.check_call\") as mock_check_call,\n        ):\n            command.execute_install_script(str(tmp_path))\n\n        mock_check_call.assert_called()\n        cmd = mock_check_call.call_args[0][0]\n        assert cmd[0] == \"/resolved/python\"\n        assert \"-m\" in cmd and \"pip\" in cmd\n\n    def test_install_py_uses_resolved_python(self, tmp_path):\n        (tmp_path / \"install.py\").write_text(\"print('install')\\n\")\n\n        with (\n            patch(\n                \"comfy_cli.command.custom_nodes.command.resolve_workspace_python\",\n                return_value=\"/resolved/python\",\n            ),\n            patch.object(command.workspace_manager, \"workspace_path\", str(tmp_path)),\n            patch(\"comfy_cli.command.custom_nodes.command.subprocess.check_call\") as mock_check_call,\n        ):\n            command.execute_install_script(str(tmp_path))\n\n        mock_check_call.assert_called()\n        cmd = mock_check_call.call_args[0][0]\n        assert cmd == [\"/resolved/python\", \"install.py\"]\n\n    def test_inline_comment_not_passed_to_pip(self, tmp_path):\n        # Issue #431 regression: inline comments in requirements.txt must not\n        # survive into the argv handed to pip. Pre-fix, the raw line was passed\n        # verbatim (e.g. \"matplotlib>=3.3.0  # note\") and pip rejected it.\n        bad_spec = \"matplotlib>=3.3.0  # For visualization\"\n        (tmp_path / \"requirements.txt\").write_text(f\"{bad_spec}\\n\")\n\n        with (\n            patch(\n                \"comfy_cli.command.custom_nodes.command.resolve_workspace_python\",\n                return_value=\"/resolved/python\",\n            ),\n            patch.object(command.workspace_manager, \"workspace_path\", str(tmp_path)),\n            patch(\"comfy_cli.command.custom_nodes.command.subprocess.check_call\") as mock_check_call,\n        ):\n            command.execute_install_script(str(tmp_path))\n\n        for call in mock_check_call.call_args_list:\n            argv = call[0][0]\n            assert bad_spec not in argv, f\"raw comment-laden spec leaked into pip argv: {argv!r}\"\n\n    def test_uses_pip_install_r(self, tmp_path):\n        # Option C: delegate requirements-file parsing to pip via `-r <path>`.\n        # This lets pip handle inline comments, line continuations, VCS URL\n        # fragments, env markers, -e, -r, --index-url, etc.\n        requirements_path = tmp_path / \"requirements.txt\"\n        requirements_path.write_text(\"numpy>=1.0\\n\")\n\n        with (\n            patch(\n                \"comfy_cli.command.custom_nodes.command.resolve_workspace_python\",\n                return_value=\"/resolved/python\",\n            ),\n            patch.object(command.workspace_manager, \"workspace_path\", str(tmp_path)),\n            patch(\"comfy_cli.command.custom_nodes.command.subprocess.check_call\") as mock_check_call,\n        ):\n            command.execute_install_script(str(tmp_path))\n\n        mock_check_call.assert_called_once()\n        argv = mock_check_call.call_args[0][0]\n        assert argv == [\"/resolved/python\", \"-m\", \"pip\", \"install\", \"-r\", str(requirements_path)]\n\n    def test_requirements_path_is_absolute_when_repo_path_is_relative(self, tmp_path, monkeypatch):\n        # try_install_script runs pip with cwd=repo_path. If requirements_path\n        # were relative, pip would resolve `-r <rel>/requirements.txt` against\n        # that cwd, producing a doubled path like <rel>/<rel>/requirements.txt.\n        # Guard: the `-r` target must be absolute regardless of the input.\n        (tmp_path / \"requirements.txt\").write_text(\"numpy>=1.0\\n\")\n        monkeypatch.chdir(tmp_path.parent)\n        relative_repo = tmp_path.name  # e.g. \"test_requirements_path...\" relative to cwd\n\n        with (\n            patch(\n                \"comfy_cli.command.custom_nodes.command.resolve_workspace_python\",\n                return_value=\"/resolved/python\",\n            ),\n            patch.object(command.workspace_manager, \"workspace_path\", str(tmp_path)),\n            patch(\"comfy_cli.command.custom_nodes.command.subprocess.check_call\") as mock_check_call,\n        ):\n            command.execute_install_script(relative_repo)\n\n        argv = mock_check_call.call_args[0][0]\n        target = argv[-1]\n        assert os.path.isabs(target), f\"-r target is not absolute: {target!r}\"\n        assert target == str(tmp_path / \"requirements.txt\")\n\n\nclass TestUpdateNodeIdCache:\n    def test_uses_resolved_python(self, tmp_path):\n        cm_cli_path = tmp_path / \"custom_nodes\" / \"ComfyUI-Manager\" / \"cm-cli.py\"\n        cm_cli_path.parent.mkdir(parents=True)\n        cm_cli_path.touch()\n\n        config_path = tmp_path / \"config\"\n        config_path.mkdir()\n\n        with (\n            patch(\n                \"comfy_cli.command.custom_nodes.command.resolve_workspace_python\",\n                return_value=\"/resolved/python\",\n            ),\n            patch.object(command.workspace_manager, \"workspace_path\", str(tmp_path)),\n            patch(\"comfy_cli.command.custom_nodes.command.ConfigManager\") as MockConfig,\n            patch(\"comfy_cli.command.custom_nodes.command.subprocess.run\") as mock_run,\n        ):\n            MockConfig.return_value.get_config_path.return_value = str(config_path)\n            command.update_node_id_cache()\n\n        cmd = mock_run.call_args[0][0]\n        assert cmd[0] == \"/resolved/python\"\n"
  },
  {
    "path": "tests/comfy_cli/test_env_checker.py",
    "content": "import os\nimport sys\nfrom types import SimpleNamespace\nfrom unittest.mock import patch\n\nimport pytest\nimport requests\n\nfrom comfy_cli.env_checker import EnvChecker, check_comfy_server_running, format_python_version\n\n_EnvCheckerCls = EnvChecker.__closure__[0].cell_contents\n\n\nclass TestFormatPythonVersion:\n    def test_modern_python(self):\n        v = SimpleNamespace(major=3, minor=12, micro=1)\n        assert format_python_version(v) == \"3.12.1\"\n\n    def test_python_39_is_modern(self):\n        v = SimpleNamespace(major=3, minor=9, micro=0)\n        assert format_python_version(v) == \"3.9.0\"\n\n    def test_python_38_is_old(self):\n        v = SimpleNamespace(major=3, minor=8, micro=5)\n        result = format_python_version(v)\n        assert \"bold red\" in result\n        assert \"3.8.5\" in result\n\n    def test_python_37_is_old(self):\n        v = SimpleNamespace(major=3, minor=7, micro=0)\n        result = format_python_version(v)\n        assert \"bold red\" in result\n\n\nclass TestCheckComfyServerRunning:\n    @patch(\"comfy_cli.env_checker.requests.get\")\n    def test_server_running(self, mock_get):\n        mock_get.return_value.status_code = 200\n        assert check_comfy_server_running() is True\n\n    @patch(\"comfy_cli.env_checker.requests.get\")\n    def test_server_not_running(self, mock_get):\n        mock_get.side_effect = requests.exceptions.ConnectionError()\n        assert check_comfy_server_running() is False\n\n    @patch(\"comfy_cli.env_checker.requests.get\")\n    def test_non_200_status(self, mock_get):\n        mock_get.return_value.status_code = 500\n        assert check_comfy_server_running() is False\n\n    @patch(\"comfy_cli.env_checker.requests.get\")\n    def test_custom_port_and_host(self, mock_get):\n        mock_get.return_value.status_code = 200\n        check_comfy_server_running(port=9999, host=\"0.0.0.0\")\n        mock_get.assert_called_with(\"http://0.0.0.0:9999/history\")\n\n\nclass TestEnvChecker:\n    @pytest.fixture\n    def checker(self):\n        inst = _EnvCheckerCls.__new__(_EnvCheckerCls)\n        inst.python_version = sys.version_info\n        inst.virtualenv_path = None\n        inst.conda_env = None\n        return inst\n\n    def test_check_detects_virtualenv(self, checker):\n        with patch.dict(os.environ, {\"VIRTUAL_ENV\": \"/path/to/venv\"}):\n            checker.check()\n        assert checker.virtualenv_path == \"/path/to/venv\"\n\n    def test_check_detects_conda(self, checker):\n        with patch.dict(os.environ, {\"CONDA_DEFAULT_ENV\": \"myenv\"}):\n            checker.check()\n        assert checker.conda_env == \"myenv\"\n\n    def test_check_no_isolated_env(self, checker):\n        env = os.environ.copy()\n        env.pop(\"VIRTUAL_ENV\", None)\n        env.pop(\"CONDA_DEFAULT_ENV\", None)\n        with patch.dict(os.environ, env, clear=True):\n            checker.check()\n        assert checker.virtualenv_path is None\n        assert checker.conda_env is None\n\n    def test_get_isolated_env_prefers_venv(self, checker):\n        checker.virtualenv_path = \"/venv\"\n        checker.conda_env = \"conda\"\n        assert checker.get_isolated_env() == \"/venv\"\n\n    def test_get_isolated_env_falls_back_to_conda(self, checker):\n        checker.conda_env = \"conda\"\n        assert checker.get_isolated_env() == \"conda\"\n\n    @patch(\"comfy_cli.env_checker.check_comfy_server_running\", return_value=True)\n    @patch(\"comfy_cli.env_checker.ConfigManager\")\n    def test_fill_print_table_server_running(self, mock_cm, mock_server, checker):\n        mock_cm.return_value.get_env_data.return_value = []\n        data = dict(checker.fill_print_table())\n        assert \"Yes\" in data[\"Comfy Server Running\"]\n\n    @patch(\"comfy_cli.env_checker.check_comfy_server_running\", return_value=False)\n    @patch(\"comfy_cli.env_checker.ConfigManager\")\n    def test_fill_print_table_server_not_running(self, mock_cm, mock_server, checker):\n        mock_cm.return_value.get_env_data.return_value = []\n        data = dict(checker.fill_print_table())\n        assert \"No\" in data[\"Comfy Server Running\"]\n"
  },
  {
    "path": "tests/comfy_cli/test_file_utils.py",
    "content": "import zipfile\r\n\r\nfrom comfy_cli import file_utils\r\n\r\n\r\ndef test_zip_files_respects_comfyignore(tmp_path, monkeypatch):\r\n    project_dir = tmp_path\r\n    (project_dir / \"keep.txt\").write_text(\"keep\", encoding=\"utf-8\")\r\n    (project_dir / \"ignore.log\").write_text(\"ignore\", encoding=\"utf-8\")\r\n    ignored_dir = project_dir / \"ignored_dir\"\r\n    ignored_dir.mkdir()\r\n    (ignored_dir / \"nested.txt\").write_text(\"nested\", encoding=\"utf-8\")\r\n\r\n    (project_dir / \".comfyignore\").write_text(\"*.log\\nignored_dir/\\n\", encoding=\"utf-8\")\r\n\r\n    zip_path = project_dir / \"node.zip\"\r\n\r\n    monkeypatch.chdir(project_dir)\r\n    monkeypatch.setattr(\r\n        file_utils,\r\n        \"list_git_tracked_files\",\r\n        lambda base_path=\".\": [\r\n            \"keep.txt\",\r\n            \"ignore.log\",\r\n            \"ignored_dir/nested.txt\",\r\n        ],\r\n    )\r\n\r\n    file_utils.zip_files(str(zip_path))\r\n\r\n    with zipfile.ZipFile(zip_path, \"r\") as zf:\r\n        names = set(zf.namelist())\r\n\r\n    assert \"keep.txt\" in names\r\n    assert \"ignore.log\" not in names\r\n    assert not any(name.startswith(\"ignored_dir/\") for name in names)\r\n\r\n\r\ndef test_zip_files_force_include_overrides_ignore(tmp_path, monkeypatch):\r\n    project_dir = tmp_path\r\n    include_dir = project_dir / \"include_me\"\r\n    include_dir.mkdir()\r\n    (include_dir / \"data.json\").write_text(\"{}\", encoding=\"utf-8\")\r\n\r\n    (project_dir / \"other.txt\").write_text(\"ok\", encoding=\"utf-8\")\r\n    (project_dir / \".comfyignore\").write_text(\"include_me/\\n\", encoding=\"utf-8\")\r\n\r\n    zip_path = project_dir / \"node.zip\"\r\n\r\n    monkeypatch.chdir(project_dir)\r\n    monkeypatch.setattr(\r\n        file_utils,\r\n        \"list_git_tracked_files\",\r\n        lambda base_path=\".\": [\r\n            \"other.txt\",\r\n            \"include_me/data.json\",\r\n        ],\r\n    )\r\n\r\n    file_utils.zip_files(str(zip_path), includes=[\"include_me\"])\r\n\r\n    with zipfile.ZipFile(zip_path, \"r\") as zf:\r\n        names = set(zf.namelist())\r\n\r\n    assert \"include_me/data.json\" in names\r\n    assert \"other.txt\" in names\r\n\r\n\r\ndef test_zip_files_without_git_falls_back_to_walk(tmp_path, monkeypatch):\r\n    project_dir = tmp_path\r\n    (project_dir / \"file.txt\").write_text(\"data\", encoding=\"utf-8\")\r\n    zip_path = project_dir / \"node.zip\"\r\n\r\n    monkeypatch.chdir(project_dir)\r\n    monkeypatch.setattr(file_utils, \"list_git_tracked_files\", lambda base_path=\".\": [])\r\n\r\n    file_utils.zip_files(str(zip_path))\r\n\r\n    with zipfile.ZipFile(zip_path, \"r\") as zf:\r\n        names = set(zf.namelist())\r\n\r\n    assert \"file.txt\" in names\r\n    assert \"node.zip\" not in names\r\n"
  },
  {
    "path": "tests/comfy_cli/test_global_python_install.py",
    "content": "\"\"\"Integration tests for the global-Python (Docker / bare-metal) install path.\n\nCovers the scenario where comfy-cli is installed via ``pip install`` or\n``uv pip install`` into the system Python (no virtualenv).  In this case\n``sys.prefix == sys.base_prefix``, and comfy-cli must install ComfyUI\ndependencies into the same global environment instead of creating a\nworkspace ``.venv``.\n\nSee https://github.com/Comfy-Org/comfy-cli/issues/393\n\"\"\"\n\nimport os\nimport sys\nfrom pathlib import Path\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\n\nfrom comfy_cli.command import install\nfrom comfy_cli.resolve_python import ensure_workspace_python\n\n\ndef _clean_env():\n    \"\"\"Context manager that removes VIRTUAL_ENV / CONDA_PREFIX for the block.\"\"\"\n    keys = (\"VIRTUAL_ENV\", \"CONDA_PREFIX\")\n    saved = {k: os.environ.pop(k, None) for k in keys}\n\n    class _Ctx:\n        def __enter__(self):\n            return self\n\n        def __exit__(self, *a):\n            for k, v in saved.items():\n                if v is not None:\n                    os.environ[k] = v\n\n    return _Ctx()\n\n\nclass TestGlobalPythonDetection:\n    \"\"\"ensure_workspace_python must return sys.executable when running from the\n    system Python and must NOT create a .venv.\"\"\"\n\n    def test_global_python_skips_venv_creation(self, tmp_path):\n        workspace = tmp_path / \"workspace\"\n        workspace.mkdir()\n\n        with (\n            _clean_env(),\n            patch(\"comfy_cli.resolve_python.sys\") as mock_sys,\n            patch(\"comfy_cli.resolve_python._is_externally_managed\", return_value=False),\n        ):\n            mock_sys.executable = \"/usr/bin/python3\"\n            mock_sys.prefix = \"/usr\"\n            mock_sys.base_prefix = \"/usr\"\n            result = ensure_workspace_python(str(workspace))\n\n        assert result == \"/usr/bin/python3\"\n        assert not (workspace / \".venv\").exists()\n        assert not (workspace / \"venv\").exists()\n\n    def test_isolated_env_creates_venv(self, tmp_path):\n        workspace = tmp_path / \"workspace\"\n        workspace.mkdir()\n\n        with _clean_env(), patch(\"comfy_cli.resolve_python.sys\") as mock_sys:\n            mock_sys.executable = sys.executable\n            mock_sys.prefix = \"/home/user/.local/pipx/venvs/comfy-cli\"\n            mock_sys.base_prefix = \"/usr\"\n            result = ensure_workspace_python(str(workspace))\n\n        assert (workspace / \".venv\").is_dir()\n        assert \".venv\" in result\n\n\nclass TestGlobalPythonInstallExecute:\n    \"\"\"install.execute with --fast-deps must pass through the global Python to\n    DependencyCompiler (no .venv indirection).\"\"\"\n\n    def _run_execute(self, tmp_path, *, fast_deps, python=\"/usr/bin/python3\"):\n        repo_dir = str(tmp_path)\n\n        with (\n            patch(\"comfy_cli.command.install.ensure_workspace_python\", return_value=python) as mock_ensure,\n            patch(\"comfy_cli.command.install.clone_comfyui\"),\n            patch(\"comfy_cli.command.install.check_comfy_repo\", return_value=(True, None)),\n            patch(\"comfy_cli.command.install.pip_install_comfyui_dependencies\") as mock_pip,\n            patch(\"comfy_cli.command.install.DependencyCompiler\") as MockCompiler,\n            patch(\"comfy_cli.command.install.WorkspaceManager\"),\n            patch(\"comfy_cli.config_manager.ConfigManager\"),\n            patch.object(install.workspace_manager, \"skip_prompting\", True),\n            patch.object(install.workspace_manager, \"setup_workspace_manager\"),\n        ):\n            MockCompiler.Install_Build_Deps = MagicMock()\n            MockCompiler.return_value = MagicMock()\n\n            install.execute(\n                url=\"https://github.com/comfyanonymous/ComfyUI.git\",\n                comfy_path=repo_dir,\n                restore=False,\n                skip_manager=True,\n                version=\"nightly\",\n                fast_deps=fast_deps,\n            )\n\n        return mock_ensure, mock_pip, MockCompiler\n\n    def test_fast_deps_global_python_skips_install_build_deps(self, tmp_path):\n        mock_ensure, mock_pip, MockCompiler = self._run_execute(tmp_path, fast_deps=True, python=sys.executable)\n\n        mock_ensure.assert_called_once_with(str(tmp_path))\n        mock_pip.assert_not_called()\n        MockCompiler.Install_Build_Deps.assert_not_called()\n        assert MockCompiler.call_args[1][\"executable\"] == sys.executable\n\n    def test_fast_deps_venv_python_calls_install_build_deps(self, tmp_path):\n        mock_ensure, mock_pip, MockCompiler = self._run_execute(\n            tmp_path, fast_deps=True, python=\"/workspace/.venv/bin/python\"\n        )\n\n        mock_pip.assert_not_called()\n        MockCompiler.Install_Build_Deps.assert_called_once_with(executable=\"/workspace/.venv/bin/python\")\n        assert MockCompiler.call_args[1][\"executable\"] == \"/workspace/.venv/bin/python\"\n\n    def test_non_fast_deps_uses_global_python(self, tmp_path):\n        mock_ensure, mock_pip, MockCompiler = self._run_execute(tmp_path, fast_deps=False)\n\n        mock_ensure.assert_called_once_with(str(tmp_path))\n        mock_pip.assert_called_once()\n        assert mock_pip.call_args[1][\"python\"] == \"/usr/bin/python3\"\n        MockCompiler.assert_not_called()\n\n\n@pytest.mark.skipif(\n    os.environ.get(\"TEST_TORCH_BACKEND\") != \"true\",\n    reason=\"Set TEST_TORCH_BACKEND=true to run integration tests that call uv pip compile\",\n)\nclass TestDependencyCompilerGlobalPython:\n    \"\"\"Integration tests: run the real DependencyCompiler compile step (no\n    mocks, requires network) using the current Python as if it were a global\n    install.  Verifies the compiled output contains expected packages and\n    correct index URLs.\"\"\"\n\n    @pytest.fixture()\n    def workspace(self, tmp_path):\n        ws = tmp_path / \"workspace\"\n        ws.mkdir()\n        (ws / \"custom_nodes\").mkdir()\n        (ws / \"requirements.txt\").write_text(\"pyyaml\\nrequests\\n\")\n        return ws\n\n    def test_compile_produces_complete_output(self, workspace):\n        from comfy_cli.uv import DependencyCompiler\n\n        dep = DependencyCompiler(\n            cwd=str(workspace),\n            executable=sys.executable,\n            gpu=None,\n            outDir=str(workspace),\n        )\n\n        dep.compile_deps()\n\n        compiled = Path(dep.out).read_text()\n        pkg_lines = [\n            ln.split(\"==\")[0].strip().lower()\n            for ln in compiled.splitlines()\n            if \"==\" in ln and not ln.strip().startswith(\"#\")\n        ]\n        assert \"pyyaml\" in pkg_lines\n        assert \"requests\" in pkg_lines\n        assert \"--index-url\" in compiled\n\n    def test_compile_nvidia_resolves_torch(self, workspace):\n        (workspace / \"requirements.txt\").write_text(\"torch\\npyyaml\\n\")\n\n        from comfy_cli.constants import GPU_OPTION\n        from comfy_cli.uv import DependencyCompiler\n\n        dep = DependencyCompiler(\n            cwd=str(workspace),\n            executable=sys.executable,\n            gpu=GPU_OPTION.NVIDIA,\n            cuda_version=\"12.6\",\n            outDir=str(workspace),\n        )\n\n        dep.compile_deps()\n\n        compiled = Path(dep.out).read_text().lower()\n        assert \"torch==\" in compiled\n        assert \"pyyaml==\" in compiled\n        assert \"https://pypi.org/simple\" in compiled\n\n    def test_install_targets_correct_python(self, workspace):\n        from comfy_cli.uv import DependencyCompiler\n\n        dep = DependencyCompiler(\n            cwd=str(workspace),\n            executable=sys.executable,\n            gpu=None,\n            outDir=str(workspace),\n        )\n        dep.compile_deps()\n\n        with patch(\"comfy_cli.uv._check_call\") as mock_call:\n            dep.install_deps()\n\n        cmd = mock_call.call_args[1].get(\"cmd\") or mock_call.call_args[0][0]\n        assert cmd[0] == str(Path(sys.executable).expanduser().absolute())\n        assert \"--requirement\" in cmd\n"
  },
  {
    "path": "tests/comfy_cli/test_install.py",
    "content": "from unittest.mock import MagicMock, patch\n\nimport pytest\n\nfrom comfy_cli.command.install import pip_install_manager, validate_version\n\n\ndef test_validate_version_nightly():\n    assert validate_version(\"nightly\") == \"nightly\"\n    assert validate_version(\"NIGHTLY\") == \"nightly\"\n\n\ndef test_validate_version_latest():\n    assert validate_version(\"latest\") == \"latest\"\n    assert validate_version(\"LATEST\") == \"latest\"\n\n\ndef test_validate_version_valid_semver():\n    assert validate_version(\"1.2.3\") == \"1.2.3\"\n    assert validate_version(\"v1.2.3\") == \"1.2.3\"\n    assert validate_version(\"1.2.3-alpha\") == \"1.2.3-alpha\"\n\n\ndef test_validate_version_invalid():\n    with pytest.raises(ValueError):\n        validate_version(\"invalid_version\")\n\n\ndef test_validate_version_empty():\n    with pytest.raises(ValueError):\n        validate_version(\"\")\n\n\nclass TestPipInstallManager:\n    @patch(\"comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli\")\n    @patch(\"comfy_cli.command.install.subprocess.run\")\n    @patch(\"os.path.exists\", return_value=True)\n    def test_success(self, mock_exists, mock_run, mock_find):\n        mock_run.return_value = MagicMock(returncode=0)\n        result = pip_install_manager(\"/fake/repo\")\n        assert result is True\n        mock_run.assert_called_once()\n\n    @patch(\"os.path.exists\", return_value=False)\n    def test_missing_requirements_file(self, mock_exists):\n        result = pip_install_manager(\"/fake/repo\")\n        assert result is False\n\n    @patch(\"comfy_cli.command.install.subprocess.run\")\n    @patch(\"os.path.exists\", return_value=True)\n    def test_pip_failure(self, mock_exists, mock_run):\n        mock_run.return_value = MagicMock(returncode=1, stderr=\"some error\")\n        result = pip_install_manager(\"/fake/repo\")\n        assert result is False\n\n    @patch(\"comfy_cli.command.install.subprocess.run\")\n    @patch(\"os.path.exists\", return_value=True)\n    def test_pip_failure_no_stderr(self, mock_exists, mock_run):\n        mock_run.return_value = MagicMock(returncode=1, stderr=\"\")\n        result = pip_install_manager(\"/fake/repo\")\n        assert result is False\n\n\n# Run the tests\nif __name__ == \"__main__\":\n    pytest.main([__file__])\n"
  },
  {
    "path": "tests/comfy_cli/test_install_python_resolution.py",
    "content": "import sys\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\n\nfrom comfy_cli import constants\nfrom comfy_cli.command import install\nfrom comfy_cli.constants import GPU_OPTION\n\n\nclass TestPipInstallComfyuiDependencies:\n    def test_uses_python_param_cpu(self, tmp_path):\n        repo_dir = str(tmp_path)\n        (tmp_path / \"requirements.txt\").write_text(\"some-package\\n\")\n\n        with patch(\"comfy_cli.command.install.subprocess.run\", return_value=MagicMock(returncode=0)) as mock_run:\n            install.pip_install_comfyui_dependencies(\n                repo_dir,\n                gpu=None,\n                plat=None,\n                cuda_version=None,\n                skip_torch_or_directml=False,\n                skip_requirement=False,\n                python=\"/resolved/python\",\n            )\n\n        for c in mock_run.call_args_list:\n            cmd = c[0][0]\n            assert cmd[0] == \"/resolved/python\", f\"Expected /resolved/python but got {cmd[0]} in {cmd}\"\n            assert cmd[0] != sys.executable\n\n\nclass TestPipInstallManager:\n    def test_uses_python_param(self, tmp_path):\n        (tmp_path / \"manager_requirements.txt\").write_text(\"comfyui-manager\\n\")\n\n        with (\n            patch(\"comfy_cli.command.install.subprocess.run\", return_value=MagicMock(returncode=0)) as mock_run,\n            patch(\"comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli\") as mock_find,\n        ):\n            mock_find.cache_clear = MagicMock()\n            install.pip_install_manager(str(tmp_path), python=\"/resolved/python\")\n\n        cmd = mock_run.call_args[0][0]\n        assert cmd[0] == \"/resolved/python\"\n\n\nclass TestExecute:\n    def test_calls_ensure_and_passes_resolved_python(self, tmp_path):\n        repo_dir = str(tmp_path)\n\n        with (\n            patch(\"comfy_cli.command.install.ensure_workspace_python\", return_value=\"/resolved/python\") as mock_ensure,\n            patch(\"comfy_cli.command.install.clone_comfyui\"),\n            patch(\"comfy_cli.command.install.check_comfy_repo\", return_value=(True, None)),\n            patch(\"comfy_cli.command.install.pip_install_comfyui_dependencies\") as mock_pip_deps,\n            patch(\"comfy_cli.command.install.WorkspaceManager\"),\n            patch(\"comfy_cli.config_manager.ConfigManager\"),\n            patch.object(install.workspace_manager, \"skip_prompting\", True),\n            patch.object(install.workspace_manager, \"setup_workspace_manager\"),\n        ):\n            install.execute(\n                url=\"https://github.com/test/test.git\",\n                comfy_path=repo_dir,\n                restore=False,\n                skip_manager=True,\n                version=\"nightly\",\n            )\n\n        mock_ensure.assert_called_once_with(repo_dir)\n        mock_pip_deps.assert_called_once()\n        assert mock_pip_deps.call_args[1][\"python\"] == \"/resolved/python\"\n\n    def test_fast_deps_passes_python_to_dependency_compiler(self, tmp_path):\n        repo_dir = str(tmp_path)\n\n        with (\n            patch(\"comfy_cli.command.install.ensure_workspace_python\", return_value=\"/resolved/python\"),\n            patch(\"comfy_cli.command.install.clone_comfyui\"),\n            patch(\"comfy_cli.command.install.check_comfy_repo\", return_value=(True, None)),\n            patch(\"comfy_cli.command.install.DependencyCompiler\") as MockCompiler,\n            patch(\"comfy_cli.command.install.WorkspaceManager\"),\n            patch(\"comfy_cli.config_manager.ConfigManager\"),\n            patch.object(install.workspace_manager, \"skip_prompting\", True),\n            patch.object(install.workspace_manager, \"setup_workspace_manager\"),\n        ):\n            MockCompiler.Install_Build_Deps = MagicMock()\n            mock_instance = MagicMock()\n            MockCompiler.return_value = mock_instance\n\n            install.execute(\n                url=\"https://github.com/test/test.git\",\n                comfy_path=repo_dir,\n                restore=False,\n                skip_manager=True,\n                version=\"nightly\",\n                fast_deps=True,\n            )\n\n        MockCompiler.Install_Build_Deps.assert_called_once_with(executable=\"/resolved/python\")\n        MockCompiler.assert_called_once()\n        assert MockCompiler.call_args[1][\"executable\"] == \"/resolved/python\"\n        assert MockCompiler.call_args[1].get(\"skip_torch\") in (None, False)\n\n    def test_fast_deps_forwards_skip_torch(self, tmp_path):\n        repo_dir = str(tmp_path)\n\n        with (\n            patch(\"comfy_cli.command.install.ensure_workspace_python\", return_value=\"/resolved/python\"),\n            patch(\"comfy_cli.command.install.clone_comfyui\"),\n            patch(\"comfy_cli.command.install.check_comfy_repo\", return_value=(True, None)),\n            patch(\"comfy_cli.command.install.DependencyCompiler\") as MockCompiler,\n            patch(\"comfy_cli.command.install.WorkspaceManager\"),\n            patch(\"comfy_cli.config_manager.ConfigManager\"),\n            patch.object(install.workspace_manager, \"skip_prompting\", True),\n            patch.object(install.workspace_manager, \"setup_workspace_manager\"),\n        ):\n            MockCompiler.Install_Build_Deps = MagicMock()\n            mock_instance = MagicMock()\n            MockCompiler.return_value = mock_instance\n\n            install.execute(\n                url=\"https://github.com/test/test.git\",\n                manager_url=\"https://github.com/test/manager.git\",\n                comfy_path=repo_dir,\n                restore=False,\n                skip_manager=True,\n                version=\"nightly\",\n                fast_deps=True,\n                skip_torch_or_directml=True,\n            )\n\n        assert MockCompiler.call_args[1][\"skip_torch\"] is True\n\n    def test_fast_deps_cuda_tag_converted_to_dotted_version(self, tmp_path):\n        repo_dir = str(tmp_path)\n\n        with (\n            patch(\"comfy_cli.command.install.ensure_workspace_python\", return_value=\"/resolved/python\"),\n            patch(\"comfy_cli.command.install.clone_comfyui\"),\n            patch(\"comfy_cli.command.install.check_comfy_repo\", return_value=(True, None)),\n            patch(\"comfy_cli.command.install.DependencyCompiler\") as MockCompiler,\n            patch(\"comfy_cli.command.install.WorkspaceManager\"),\n            patch(\"comfy_cli.config_manager.ConfigManager\"),\n            patch.object(install.workspace_manager, \"skip_prompting\", True),\n            patch.object(install.workspace_manager, \"setup_workspace_manager\"),\n        ):\n            MockCompiler.Install_Build_Deps = MagicMock()\n            MockCompiler.return_value = MagicMock()\n\n            install.execute(\n                url=\"https://github.com/test/test.git\",\n                comfy_path=repo_dir,\n                restore=False,\n                skip_manager=True,\n                version=\"nightly\",\n                fast_deps=True,\n                gpu=GPU_OPTION.NVIDIA,\n                cuda_tag=\"cu130\",\n            )\n\n        assert MockCompiler.call_args[1][\"cuda_version\"] == \"13.0\"\n\n    def test_fast_deps_explicit_cuda_version_no_tag(self, tmp_path):\n        repo_dir = str(tmp_path)\n\n        with (\n            patch(\"comfy_cli.command.install.ensure_workspace_python\", return_value=\"/resolved/python\"),\n            patch(\"comfy_cli.command.install.clone_comfyui\"),\n            patch(\"comfy_cli.command.install.check_comfy_repo\", return_value=(True, None)),\n            patch(\"comfy_cli.command.install.DependencyCompiler\") as MockCompiler,\n            patch(\"comfy_cli.command.install.WorkspaceManager\"),\n            patch(\"comfy_cli.config_manager.ConfigManager\"),\n            patch.object(install.workspace_manager, \"skip_prompting\", True),\n            patch.object(install.workspace_manager, \"setup_workspace_manager\"),\n        ):\n            MockCompiler.Install_Build_Deps = MagicMock()\n            MockCompiler.return_value = MagicMock()\n\n            install.execute(\n                url=\"https://github.com/test/test.git\",\n                comfy_path=repo_dir,\n                restore=False,\n                skip_manager=True,\n                version=\"nightly\",\n                fast_deps=True,\n                gpu=GPU_OPTION.NVIDIA,\n                cuda_version=constants.CUDAVersion.v12_6,\n            )\n\n        assert MockCompiler.call_args[1][\"cuda_version\"] == \"12.6\"\n\n\nclass TestAutoDetectIntegration:\n    def test_auto_detected_cuda_tag_used(self, tmp_path):\n        repo_dir = str(tmp_path)\n        (tmp_path / \"requirements.txt\").write_text(\"some-package\\n\")\n\n        with patch(\"comfy_cli.command.install.subprocess.run\", return_value=MagicMock(returncode=0)) as mock_run:\n            install.pip_install_comfyui_dependencies(\n                repo_dir,\n                gpu=GPU_OPTION.NVIDIA,\n                plat=constants.OS.LINUX,\n                cuda_version=None,\n                skip_torch_or_directml=False,\n                skip_requirement=False,\n                python=\"/usr/bin/python\",\n                cuda_tag=\"cu130\",\n            )\n\n        cmd = _get_torch_install_cmd(mock_run.call_args_list)\n        assert \"https://download.pytorch.org/whl/cu130\" in cmd\n\n    def test_auto_detect_failure_falls_back(self, tmp_path):\n        repo_dir = str(tmp_path)\n        (tmp_path / \"requirements.txt\").write_text(\"some-package\\n\")\n\n        with patch(\"comfy_cli.command.install.subprocess.run\", return_value=MagicMock(returncode=0)) as mock_run:\n            install.pip_install_comfyui_dependencies(\n                repo_dir,\n                gpu=GPU_OPTION.NVIDIA,\n                plat=constants.OS.LINUX,\n                cuda_version=None,\n                skip_torch_or_directml=False,\n                skip_requirement=False,\n                python=\"/usr/bin/python\",\n                cuda_tag=None,\n            )\n\n        cmd = _get_torch_install_cmd(mock_run.call_args_list)\n        assert \"https://download.pytorch.org/whl/cu126\" in cmd\n\n    def test_explicit_cuda_version_used_when_no_tag(self, tmp_path):\n        repo_dir = str(tmp_path)\n        (tmp_path / \"requirements.txt\").write_text(\"some-package\\n\")\n\n        with patch(\"comfy_cli.command.install.subprocess.run\", return_value=MagicMock(returncode=0)) as mock_run:\n            install.pip_install_comfyui_dependencies(\n                repo_dir,\n                gpu=GPU_OPTION.NVIDIA,\n                plat=constants.OS.LINUX,\n                cuda_version=constants.CUDAVersion.v11_8,\n                skip_torch_or_directml=False,\n                skip_requirement=False,\n                python=\"/usr/bin/python\",\n                cuda_tag=None,\n            )\n\n        cmd = _get_torch_install_cmd(mock_run.call_args_list)\n        assert \"https://download.pytorch.org/whl/cu118\" in cmd\n\n    def test_cuda_tag_takes_precedence_over_enum(self, tmp_path):\n        repo_dir = str(tmp_path)\n        (tmp_path / \"requirements.txt\").write_text(\"some-package\\n\")\n\n        with patch(\"comfy_cli.command.install.subprocess.run\", return_value=MagicMock(returncode=0)) as mock_run:\n            install.pip_install_comfyui_dependencies(\n                repo_dir,\n                gpu=GPU_OPTION.NVIDIA,\n                plat=constants.OS.LINUX,\n                cuda_version=constants.CUDAVersion.v11_8,\n                skip_torch_or_directml=False,\n                skip_requirement=False,\n                python=\"/usr/bin/python\",\n                cuda_tag=\"cu130\",\n            )\n\n        cmd = _get_torch_install_cmd(mock_run.call_args_list)\n        assert \"https://download.pytorch.org/whl/cu130\" in cmd\n\n\ndef _get_torch_install_cmd(calls):\n    \"\"\"Find the subprocess.run call that installs torch packages.\"\"\"\n    for c in calls:\n        cmd = c[0][0]\n        if \"torch\" in cmd and \"requirements.txt\" not in cmd:\n            return cmd\n    return None\n\n\nclass TestTorchInstallCommands:\n    @pytest.mark.parametrize(\n        \"rocm_version,expected_url\",\n        [\n            (constants.ROCmVersion.v7_1, \"https://download.pytorch.org/whl/rocm7.1\"),\n            (constants.ROCmVersion.v7_0, \"https://download.pytorch.org/whl/rocm7.0\"),\n            (constants.ROCmVersion.v6_3, \"https://download.pytorch.org/whl/rocm6.3\"),\n            (constants.ROCmVersion.v6_2, \"https://download.pytorch.org/whl/rocm6.2\"),\n            (constants.ROCmVersion.v6_1, \"https://download.pytorch.org/whl/rocm6.1\"),\n        ],\n    )\n    def test_amd_uses_index_url_with_rocm_version(self, tmp_path, rocm_version, expected_url):\n        repo_dir = str(tmp_path)\n        (tmp_path / \"requirements.txt\").write_text(\"some-package\\n\")\n\n        with patch(\"comfy_cli.command.install.subprocess.run\", return_value=MagicMock(returncode=0)) as mock_run:\n            install.pip_install_comfyui_dependencies(\n                repo_dir,\n                gpu=GPU_OPTION.AMD,\n                plat=constants.OS.LINUX,\n                cuda_version=constants.CUDAVersion.v12_6,\n                skip_torch_or_directml=False,\n                skip_requirement=False,\n                python=\"/usr/bin/python\",\n                rocm_version=rocm_version,\n            )\n\n        cmd = _get_torch_install_cmd(mock_run.call_args_list)\n        assert \"--index-url\" in cmd\n        assert \"--extra-index-url\" not in cmd\n        assert expected_url in cmd\n\n    @pytest.mark.parametrize(\n        \"cuda_version,expected_url\",\n        [\n            (constants.CUDAVersion.v12_9, \"https://download.pytorch.org/whl/cu129\"),\n            (constants.CUDAVersion.v12_6, \"https://download.pytorch.org/whl/cu126\"),\n            (constants.CUDAVersion.v12_4, \"https://download.pytorch.org/whl/cu124\"),\n            (constants.CUDAVersion.v12_1, \"https://download.pytorch.org/whl/cu121\"),\n            (constants.CUDAVersion.v11_8, \"https://download.pytorch.org/whl/cu118\"),\n        ],\n    )\n    def test_nvidia_uses_index_url_with_cuda_version(self, tmp_path, cuda_version, expected_url):\n        repo_dir = str(tmp_path)\n        (tmp_path / \"requirements.txt\").write_text(\"some-package\\n\")\n\n        with patch(\"comfy_cli.command.install.subprocess.run\", return_value=MagicMock(returncode=0)) as mock_run:\n            install.pip_install_comfyui_dependencies(\n                repo_dir,\n                gpu=GPU_OPTION.NVIDIA,\n                plat=constants.OS.WINDOWS,\n                cuda_version=cuda_version,\n                skip_torch_or_directml=False,\n                skip_requirement=False,\n                python=\"/usr/bin/python\",\n            )\n\n        cmd = _get_torch_install_cmd(mock_run.call_args_list)\n        assert \"--index-url\" in cmd\n        assert \"--extra-index-url\" not in cmd\n        assert expected_url in cmd\n\n    def test_nvidia_linux_uses_index_url(self, tmp_path):\n        repo_dir = str(tmp_path)\n        (tmp_path / \"requirements.txt\").write_text(\"some-package\\n\")\n\n        with patch(\"comfy_cli.command.install.subprocess.run\", return_value=MagicMock(returncode=0)) as mock_run:\n            install.pip_install_comfyui_dependencies(\n                repo_dir,\n                gpu=GPU_OPTION.NVIDIA,\n                plat=constants.OS.LINUX,\n                cuda_version=constants.CUDAVersion.v12_6,\n                skip_torch_or_directml=False,\n                skip_requirement=False,\n                python=\"/usr/bin/python\",\n            )\n\n        cmd = _get_torch_install_cmd(mock_run.call_args_list)\n        assert \"--index-url\" in cmd\n        assert \"https://download.pytorch.org/whl/cu126\" in cmd\n"
  },
  {
    "path": "tests/comfy_cli/test_launch_python_resolution.py",
    "content": "import subprocess\nimport sys\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\n\nfrom comfy_cli.command import launch\n\n\nclass TestLaunchComfyui:\n    def test_uses_python_param(self):\n        mock_result = subprocess.CompletedProcess(args=[], returncode=0)\n\n        with (\n            patch(\"comfy_cli.command.launch.ConfigManager\"),\n            patch(\"comfy_cli.command.launch.subprocess.run\", return_value=mock_result) as mock_run,\n        ):\n            with pytest.raises(SystemExit):\n                launch.launch_comfyui(extra=[], python=\"/resolved/python\")\n\n        mock_run.assert_called()\n        cmd = mock_run.call_args[0][0]\n        assert cmd[0] == \"/resolved/python\"\n        assert cmd[0] != sys.executable\n\n    @pytest.mark.parametrize(\"returncode\", [0, 1, 42])\n    def test_foreground_exit_code_matches_subprocess(self, returncode):\n        \"\"\"exit() should receive the subprocess returncode, not the CompletedProcess object.\"\"\"\n        mock_result = subprocess.CompletedProcess(args=[], returncode=returncode)\n\n        with (\n            patch(\"comfy_cli.command.launch.ConfigManager\"),\n            patch(\"comfy_cli.command.launch.subprocess.run\", return_value=mock_result),\n        ):\n            with pytest.raises(SystemExit) as exc_info:\n                launch.launch_comfyui(extra=[], python=\"/resolved/python\")\n\n        assert exc_info.value.code == returncode\n\n\nclass TestLaunchResolvesWorkspacePython:\n    def test_resolves_and_passes_python(self):\n        with (\n            patch(\"comfy_cli.command.launch.resolve_workspace_python\", return_value=\"/resolved/python\") as mock_resolve,\n            patch.object(launch.workspace_manager, \"workspace_path\", \"/fake/workspace\"),\n            patch.object(launch.workspace_manager, \"workspace_type\", launch.WorkspaceType.DEFAULT),\n            patch.object(launch.workspace_manager, \"config_manager\", MagicMock()),\n            patch.object(launch.workspace_manager, \"set_recent_workspace\"),\n            patch(\"comfy_cli.command.launch.check_for_updates\"),\n            patch(\"comfy_cli.command.launch.os.chdir\"),\n            patch(\"comfy_cli.command.launch.launch_comfyui\") as mock_launch_comfyui,\n        ):\n            launch.launch(background=False)\n\n        mock_resolve.assert_called_once_with(\"/fake/workspace\")\n        mock_launch_comfyui.assert_called_once()\n        assert mock_launch_comfyui.call_args[1][\"python\"] == \"/resolved/python\"\n"
  },
  {
    "path": "tests/comfy_cli/test_models_python_resolution.py",
    "content": "import builtins\nfrom unittest.mock import patch\n\nimport typer.testing\n\nfrom comfy_cli.command.models.models import app\n\nrunner = typer.testing.CliRunner()\n\n_real_import = builtins.__import__\n\n\ndef _block_huggingface_hub(name, *args, **kwargs):\n    if name == \"huggingface_hub\":\n        raise ImportError(\"blocked by test\")\n    return _real_import(name, *args, **kwargs)\n\n\nclass TestDownloadHuggingfacePipInstall:\n    def test_uses_resolved_python(self, tmp_path):\n        workspace = tmp_path / \"workspace\"\n        workspace.mkdir()\n\n        url = \"https://huggingface.co/CompVis/stable-diffusion-v1-4/resolve/main/sd-v1-4.ckpt\"\n\n        with (\n            patch(\"comfy_cli.command.models.models.get_workspace\", return_value=workspace),\n            patch(\"comfy_cli.command.models.models.check_unauthorized\", return_value=True),\n            patch(\n                \"comfy_cli.command.models.models.config_manager.get_or_override\",\n                side_effect=lambda env_key, config_key, set_value=None: \"fake-hf-token\" if \"HF\" in env_key else None,\n            ),\n            patch(\"builtins.__import__\", side_effect=_block_huggingface_hub),\n            patch(\"comfy_cli.resolve_python.resolve_workspace_python\", return_value=\"/resolved/python\"),\n            patch(\"subprocess.check_call\") as mock_check_call,\n        ):\n            result = runner.invoke(\n                app,\n                [\n                    \"download\",\n                    \"--url\",\n                    url,\n                    \"--relative-path\",\n                    \"models\",\n                    \"--filename\",\n                    \"sd-v1-4.ckpt\",\n                ],\n            )\n\n        assert mock_check_call.called, f\"check_call not called; output: {result.output}\"\n        cmd = mock_check_call.call_args[0][0]\n        assert cmd[0] == \"/resolved/python\"\n        assert cmd == [\"/resolved/python\", \"-m\", \"pip\", \"install\", \"huggingface_hub\"]\n"
  },
  {
    "path": "tests/comfy_cli/test_resolve_python.py",
    "content": "import os\nimport subprocess\nimport sys\nfrom unittest.mock import patch\n\nimport pytest\n\nfrom comfy_cli.resolve_python import (\n    _get_python_binary,\n    _is_externally_managed,\n    create_workspace_venv,\n    ensure_workspace_python,\n    resolve_workspace_python,\n)\n\n\ndef _clean_env(**overrides):\n    \"\"\"Return a patch.dict that clears VIRTUAL_ENV and CONDA_PREFIX, then applies overrides.\"\"\"\n    removals = {k: overrides.pop(k, None) for k in (\"VIRTUAL_ENV\", \"CONDA_PREFIX\")}\n    env = {k: v for k, v in overrides.items()}\n    env.update({k: v for k, v in removals.items() if v is not None})\n    keys_to_remove = [k for k, v in removals.items() if v is None and k in os.environ]\n\n    class _Ctx:\n        def __enter__(self_ctx):\n            self_ctx._old = {k: os.environ.get(k) for k in keys_to_remove}\n            for k in keys_to_remove:\n                os.environ.pop(k, None)\n            self_ctx._patcher = patch.dict(os.environ, env, clear=False)\n            self_ctx._patcher.__enter__()\n            return self_ctx\n\n        def __exit__(self_ctx, *args):\n            self_ctx._patcher.__exit__(*args)\n            for k, v in self_ctx._old.items():\n                if v is not None:\n                    os.environ[k] = v\n\n    return _Ctx()\n\n\ndef _make_fake_python(base_dir, name=\"bin/python\"):\n    \"\"\"Create a fake python binary (empty file) and return its path.\"\"\"\n    p = base_dir / name\n    p.parent.mkdir(parents=True, exist_ok=True)\n    p.touch()\n    return p\n\n\ndef _make_real_venv(base_dir):\n    \"\"\"Create a real venv and return the python path inside it.\"\"\"\n    subprocess.run([sys.executable, \"-m\", \"venv\", str(base_dir)], check=True)\n    python = os.path.join(str(base_dir), \"bin\", \"python\")\n    assert os.path.isfile(python)\n    return python\n\n\nclass TestIsExternallyManaged:\n    def test_true_when_marker_exists(self, tmp_path):\n        marker = tmp_path / \"EXTERNALLY-MANAGED\"\n        marker.touch()\n        with patch(\"comfy_cli.resolve_python.sysconfig.get_path\", return_value=str(tmp_path)):\n            assert _is_externally_managed() is True\n\n    def test_false_when_no_marker(self, tmp_path):\n        with patch(\"comfy_cli.resolve_python.sysconfig.get_path\", return_value=str(tmp_path)):\n            assert _is_externally_managed() is False\n\n    def test_false_when_stdlib_is_none(self):\n        with patch(\"comfy_cli.resolve_python.sysconfig.get_path\", return_value=None):\n            assert _is_externally_managed() is False\n\n\nclass TestGetPythonBinary:\n    @patch(\"comfy_cli.resolve_python.platform.system\", return_value=\"Linux\")\n    def test_unix(self, _mock):\n        assert _get_python_binary(\"/some/env\") == \"/some/env/bin/python\"\n\n    @patch(\"comfy_cli.resolve_python.platform.system\", return_value=\"Darwin\")\n    def test_macos(self, _mock):\n        assert _get_python_binary(\"/some/env\") == \"/some/env/bin/python\"\n\n    @patch(\"comfy_cli.resolve_python.platform.system\", return_value=\"Windows\")\n    def test_windows(self, _mock):\n        result = _get_python_binary(\"/some/env\")\n        assert result.endswith(os.path.join(\"Scripts\", \"python.exe\"))\n\n\nclass TestResolveWorkspacePython:\n    def test_virtual_env_takes_precedence_over_workspace_venv(self, tmp_path):\n        venv_dir = tmp_path / \"user_venv\"\n        python = _make_fake_python(venv_dir)\n        workspace = tmp_path / \"workspace\"\n        _make_fake_python(workspace / \".venv\")\n\n        with _clean_env(VIRTUAL_ENV=str(venv_dir)):\n            result = resolve_workspace_python(str(workspace))\n        assert result == str(python)\n\n    def test_virtual_env_takes_precedence_over_conda(self, tmp_path):\n        venv_dir = tmp_path / \"user_venv\"\n        venv_python = _make_fake_python(venv_dir)\n        conda_dir = tmp_path / \"conda_env\"\n        _make_fake_python(conda_dir)\n\n        with _clean_env(VIRTUAL_ENV=str(venv_dir), CONDA_PREFIX=str(conda_dir)):\n            result = resolve_workspace_python(None)\n        assert result == str(venv_python)\n\n    def test_conda_prefix_when_no_virtual_env(self, tmp_path):\n        conda_dir = tmp_path / \"conda\"\n        python = _make_fake_python(conda_dir)\n\n        with _clean_env(CONDA_PREFIX=str(conda_dir)):\n            result = resolve_workspace_python(str(tmp_path / \"workspace\"))\n        assert result == str(python)\n\n    def test_conda_prefix_python_missing_falls_through(self, tmp_path):\n        conda_dir = tmp_path / \"broken_conda\"\n        conda_dir.mkdir()\n\n        with _clean_env(CONDA_PREFIX=str(conda_dir)):\n            result = resolve_workspace_python(None)\n        assert result == sys.executable\n\n    def test_virtual_env_missing_falls_to_conda(self, tmp_path):\n        broken_venv = tmp_path / \"broken_venv\"\n        broken_venv.mkdir()\n        conda_dir = tmp_path / \"conda\"\n        conda_python = _make_fake_python(conda_dir)\n\n        with _clean_env(VIRTUAL_ENV=str(broken_venv), CONDA_PREFIX=str(conda_dir)):\n            result = resolve_workspace_python(None)\n        assert result == str(conda_python)\n\n    def test_workspace_dot_venv_found(self, tmp_path):\n        workspace = tmp_path / \"workspace\"\n        python = _make_fake_python(workspace / \".venv\")\n\n        with _clean_env():\n            result = resolve_workspace_python(str(workspace))\n        assert result == str(python)\n\n    def test_workspace_venv_found(self, tmp_path):\n        workspace = tmp_path / \"workspace\"\n        python = _make_fake_python(workspace / \"venv\")\n\n        with _clean_env():\n            result = resolve_workspace_python(str(workspace))\n        assert result == str(python)\n\n    def test_dot_venv_preferred_over_venv(self, tmp_path):\n        workspace = tmp_path / \"workspace\"\n        dot_python = _make_fake_python(workspace / \".venv\")\n        _make_fake_python(workspace / \"venv\")\n\n        with _clean_env():\n            result = resolve_workspace_python(str(workspace))\n        assert result == str(dot_python)\n\n    def test_workspace_dot_venv_dir_exists_but_python_missing(self, tmp_path):\n        workspace = tmp_path / \"workspace\"\n        (workspace / \".venv\").mkdir(parents=True)\n\n        with _clean_env():\n            result = resolve_workspace_python(str(workspace))\n        assert result == sys.executable\n\n    def test_workspace_dot_venv_broken_falls_to_venv(self, tmp_path):\n        workspace = tmp_path / \"workspace\"\n        (workspace / \".venv\").mkdir(parents=True)\n        venv_python = _make_fake_python(workspace / \"venv\")\n\n        with _clean_env():\n            result = resolve_workspace_python(str(workspace))\n        assert result == str(venv_python)\n\n    def test_workspace_venv_dir_exists_but_python_missing(self, tmp_path):\n        workspace = tmp_path / \"workspace\"\n        (workspace / \"venv\").mkdir(parents=True)\n\n        with _clean_env():\n            result = resolve_workspace_python(str(workspace))\n        assert result == sys.executable\n\n    def test_fallback_to_sys_executable(self, tmp_path):\n        with _clean_env():\n            result = resolve_workspace_python(str(tmp_path))\n        assert result == sys.executable\n\n    def test_none_workspace_path(self):\n        with _clean_env():\n            result = resolve_workspace_python(None)\n        assert result == sys.executable\n\n    def test_virtual_env_python_missing_falls_through(self, tmp_path):\n        venv_dir = tmp_path / \"broken_venv\"\n        venv_dir.mkdir()\n\n        with _clean_env(VIRTUAL_ENV=str(venv_dir)):\n            result = resolve_workspace_python(str(tmp_path))\n        assert result == sys.executable\n\n    def test_with_real_venv(self, tmp_path):\n        workspace = tmp_path / \"workspace\"\n        workspace.mkdir()\n        expected = _make_real_venv(workspace / \".venv\")\n\n        with _clean_env():\n            result = resolve_workspace_python(str(workspace))\n        assert result == expected\n        r = subprocess.run([result, \"-c\", \"print('ok')\"], capture_output=True, text=True)\n        assert r.returncode == 0\n        assert r.stdout.strip() == \"ok\"\n\n\nclass TestEnsureWorkspacePython:\n    def test_with_virtual_env_does_not_create_venv(self, tmp_path):\n        venv_dir = tmp_path / \"ext_venv\"\n        python = _make_fake_python(venv_dir)\n        workspace = tmp_path / \"workspace\"\n        workspace.mkdir()\n\n        with _clean_env(VIRTUAL_ENV=str(venv_dir)):\n            result = ensure_workspace_python(str(workspace))\n        assert result == str(python)\n        assert not (workspace / \".venv\").exists()\n\n    def test_with_conda_does_not_create_venv(self, tmp_path):\n        conda_dir = tmp_path / \"conda\"\n        python = _make_fake_python(conda_dir)\n        workspace = tmp_path / \"workspace\"\n        workspace.mkdir()\n\n        with _clean_env(CONDA_PREFIX=str(conda_dir)):\n            result = ensure_workspace_python(str(workspace))\n        assert result == str(python)\n        assert not (workspace / \".venv\").exists()\n\n    def test_with_both_env_vars_uses_virtual_env(self, tmp_path):\n        venv_dir = tmp_path / \"venv\"\n        venv_python = _make_fake_python(venv_dir)\n        conda_dir = tmp_path / \"conda\"\n        _make_fake_python(conda_dir)\n        workspace = tmp_path / \"workspace\"\n        workspace.mkdir()\n\n        with _clean_env(VIRTUAL_ENV=str(venv_dir), CONDA_PREFIX=str(conda_dir)):\n            result = ensure_workspace_python(str(workspace))\n        assert result == str(venv_python)\n        assert not (workspace / \".venv\").exists()\n\n    def test_global_python_returns_sys_executable(self, tmp_path):\n        workspace = tmp_path / \"workspace\"\n        workspace.mkdir()\n\n        with (\n            _clean_env(),\n            patch(\"comfy_cli.resolve_python.sys\") as mock_sys,\n            patch(\"comfy_cli.resolve_python._is_externally_managed\", return_value=False),\n        ):\n            mock_sys.executable = \"/usr/bin/python3\"\n            mock_sys.prefix = \"/usr\"\n            mock_sys.base_prefix = \"/usr\"\n            result = ensure_workspace_python(str(workspace))\n\n        assert result == \"/usr/bin/python3\"\n        assert not (workspace / \".venv\").exists()\n\n    def test_global_python_pep668_creates_venv(self, tmp_path):\n        workspace = tmp_path / \"workspace\"\n        workspace.mkdir()\n\n        with (\n            _clean_env(),\n            patch(\"comfy_cli.resolve_python.sys\") as mock_sys,\n            patch(\"comfy_cli.resolve_python._is_externally_managed\", return_value=True),\n        ):\n            mock_sys.executable = sys.executable\n            mock_sys.prefix = \"/usr\"\n            mock_sys.base_prefix = \"/usr\"\n            result = ensure_workspace_python(str(workspace))\n\n        assert (workspace / \".venv\").is_dir()\n        assert os.path.isfile(result)\n        assert \".venv\" in result\n\n    def test_creates_venv_when_isolated_env(self, tmp_path):\n        workspace = tmp_path / \"workspace\"\n        workspace.mkdir()\n\n        with _clean_env():\n            # Simulate isolated env (pipx/uv tool): prefix != base_prefix\n            with patch(\"comfy_cli.resolve_python.sys\") as mock_sys:\n                mock_sys.executable = sys.executable\n                mock_sys.prefix = \"/home/user/.local/pipx/venvs/comfy-cli\"\n                mock_sys.base_prefix = \"/usr\"\n                result = ensure_workspace_python(str(workspace))\n\n        assert (workspace / \".venv\").is_dir()\n        assert os.path.isfile(result)\n        assert \".venv\" in result\n        r = subprocess.run([result, \"-c\", \"print('ok')\"], capture_output=True, text=True)\n        assert r.returncode == 0\n\n    def test_existing_dot_venv_reused(self, tmp_path):\n        workspace = tmp_path / \"workspace\"\n        python = _make_fake_python(workspace / \".venv\")\n\n        with _clean_env():\n            result = ensure_workspace_python(str(workspace))\n        assert result == str(python)\n\n    def test_existing_venv_reused(self, tmp_path):\n        workspace = tmp_path / \"workspace\"\n        python = _make_fake_python(workspace / \"venv\")\n\n        with _clean_env():\n            result = ensure_workspace_python(str(workspace))\n        assert result == str(python)\n\n    def test_broken_dot_venv_falls_to_venv(self, tmp_path):\n        workspace = tmp_path / \"workspace\"\n        (workspace / \".venv\").mkdir(parents=True)\n        venv_python = _make_fake_python(workspace / \"venv\")\n\n        with _clean_env():\n            result = ensure_workspace_python(str(workspace))\n        assert result == str(venv_python)\n\n    def test_broken_dot_venv_global_python_returns_sys_executable(self, tmp_path):\n        workspace = tmp_path / \"workspace\"\n        (workspace / \".venv\").mkdir(parents=True)\n\n        with (\n            _clean_env(),\n            patch(\"comfy_cli.resolve_python.sys\") as mock_sys,\n            patch(\"comfy_cli.resolve_python._is_externally_managed\", return_value=False),\n        ):\n            mock_sys.executable = \"/usr/bin/python3\"\n            mock_sys.prefix = \"/usr\"\n            mock_sys.base_prefix = \"/usr\"\n            result = ensure_workspace_python(str(workspace))\n        assert result == \"/usr/bin/python3\"\n\n    def test_broken_dot_venv_isolated_env_creates_new(self, tmp_path):\n        workspace = tmp_path / \"workspace\"\n        (workspace / \".venv\").mkdir(parents=True)\n\n        with _clean_env(), patch(\"comfy_cli.resolve_python.sys\") as mock_sys:\n            mock_sys.executable = sys.executable\n            mock_sys.prefix = \"/home/user/.local/pipx/venvs/comfy-cli\"\n            mock_sys.base_prefix = \"/usr\"\n            result = ensure_workspace_python(str(workspace))\n        assert os.path.isfile(result)\n        assert \".venv\" in result\n        r = subprocess.run([result, \"-c\", \"print('ok')\"], capture_output=True, text=True)\n        assert r.returncode == 0\n\n\nclass TestCreateWorkspaceVenv:\n    def test_creates_working_venv(self, tmp_path):\n        workspace = tmp_path / \"workspace\"\n        workspace.mkdir()\n\n        result = create_workspace_venv(str(workspace))\n        assert (workspace / \".venv\").is_dir()\n        assert os.path.isfile(result)\n        r = subprocess.run([result, \"-c\", \"import sys; print(sys.prefix)\"], capture_output=True, text=True)\n        assert r.returncode == 0\n        assert str(workspace) in r.stdout.strip()\n\n    def test_created_venv_has_pip(self, tmp_path):\n        workspace = tmp_path / \"workspace\"\n        workspace.mkdir()\n\n        result = create_workspace_venv(str(workspace))\n        r = subprocess.run([result, \"-m\", \"pip\", \"--version\"], capture_output=True, text=True)\n        assert r.returncode == 0\n        assert \"pip\" in r.stdout\n\n    def test_created_venv_is_isolated(self, tmp_path):\n        workspace = tmp_path / \"workspace\"\n        workspace.mkdir()\n\n        result = create_workspace_venv(str(workspace))\n        assert result != sys.executable\n        r = subprocess.run([result, \"-c\", \"import sys; print(sys.prefix)\"], capture_output=True, text=True)\n        assert r.returncode == 0\n        assert r.stdout.strip() != sys.prefix\n\n    def test_returns_platform_specific_path(self, tmp_path):\n        workspace = tmp_path / \"workspace\"\n        workspace.mkdir()\n\n        result = create_workspace_venv(str(workspace))\n        if sys.platform == \"win32\":\n            assert \"Scripts\" in result\n        else:\n            assert \"bin\" in result\n\n    def test_idempotent(self, tmp_path):\n        workspace = tmp_path / \"workspace\"\n        workspace.mkdir()\n\n        result1 = create_workspace_venv(str(workspace))\n        result2 = create_workspace_venv(str(workspace))\n        assert result1 == result2\n        assert os.path.isfile(result2)\n        r = subprocess.run([result2, \"-c\", \"print('ok')\"], capture_output=True, text=True)\n        assert r.returncode == 0\n\n    def test_failure_raises(self, tmp_path):\n        workspace = tmp_path / \"workspace\"\n        workspace.mkdir()\n\n        with patch(\"comfy_cli.resolve_python.subprocess.run\", side_effect=subprocess.CalledProcessError(1, \"venv\")):\n            with pytest.raises(subprocess.CalledProcessError):\n                create_workspace_venv(str(workspace))\n"
  },
  {
    "path": "tests/comfy_cli/test_standalone.py",
    "content": "import os\nimport re\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\nimport requests\n\nfrom comfy_cli.standalone import (\n    _latest_release_json_url,\n    _resolve_python_version,\n    download_standalone_python,\n)\n\n# Minimal SHA256SUMS content matching real format\nSAMPLE_SHA256SUMS = \"\"\"\\\naaa  cpython-3.10.20+20260310-aarch64-apple-darwin-install_only.tar.gz\nbbb  cpython-3.10.20+20260310-x86_64-pc-windows-msvc-install_only.tar.gz\nccc  cpython-3.12.13+20260310-aarch64-apple-darwin-install_only.tar.gz\nddd  cpython-3.12.13+20260310-x86_64-pc-windows-msvc-install_only.tar.gz\neee  cpython-3.12.13+20260310-x86_64_v3-unknown-linux-gnu-install_only.tar.gz\nfff  cpython-3.13.12+20260310-x86_64-pc-windows-msvc-install_only.tar.gz\n\"\"\"\n\n\ndef _mock_response(text, status_code=200):\n    resp = MagicMock()\n    resp.text = text\n    resp.status_code = status_code\n    resp.raise_for_status = MagicMock()\n    if status_code != 200:\n        resp.raise_for_status.side_effect = Exception(f\"HTTP {status_code}\")\n    return resp\n\n\nclass TestResolvePythonVersion:\n    @patch(\"comfy_cli.standalone.requests.get\")\n    def test_resolves_312(self, mock_get):\n        mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS)\n        result = _resolve_python_version(\"https://example.com/release\", \"3.12\")\n        assert result == \"3.12.13\"\n\n    @patch(\"comfy_cli.standalone.requests.get\")\n    def test_resolves_310(self, mock_get):\n        mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS)\n        result = _resolve_python_version(\"https://example.com/release\", \"3.10\")\n        assert result == \"3.10.20\"\n\n    @patch(\"comfy_cli.standalone.requests.get\")\n    def test_resolves_313(self, mock_get):\n        mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS)\n        result = _resolve_python_version(\"https://example.com/release\", \"3.13\")\n        assert result == \"3.13.12\"\n\n    @patch(\"comfy_cli.standalone.requests.get\")\n    def test_missing_version_raises(self, mock_get):\n        mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS)\n        with pytest.raises(RuntimeError, match=\"No Python 3.14.x found\"):\n            _resolve_python_version(\"https://example.com/release\", \"3.14\")\n\n    @patch(\"comfy_cli.standalone.requests.get\")\n    def test_http_error_propagates(self, mock_get):\n        mock_get.return_value = _mock_response(\"\", status_code=404)\n        with pytest.raises(Exception, match=\"HTTP 404\"):\n            _resolve_python_version(\"https://example.com/release\", \"3.12\")\n\n    @patch(\"comfy_cli.standalone.requests.get\")\n    def test_picks_highest_patch(self, mock_get):\n        \"\"\"If multiple patch versions exist for a minor series, pick the highest.\"\"\"\n        sha256sums = \"\"\"\\\naaa  cpython-3.12.10+20260310-x86_64-install_only.tar.gz\nbbb  cpython-3.12.13+20260310-x86_64-install_only.tar.gz\nccc  cpython-3.12.9+20260310-x86_64-install_only.tar.gz\n\"\"\"\n        mock_get.return_value = _mock_response(sha256sums)\n        result = _resolve_python_version(\"https://example.com/release\", \"3.12\")\n        assert result == \"3.12.13\"\n\n    @patch(\"comfy_cli.standalone.requests.get\")\n    def test_url_construction(self, mock_get):\n        mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS)\n        _resolve_python_version(\"https://example.com/release/\", \"3.12\")\n        mock_get.assert_called_once_with(\"https://example.com/release/SHA256SUMS\")\n\n    @patch(\"comfy_cli.standalone.requests.get\")\n    def test_no_false_match_across_minor(self, mock_get):\n        \"\"\"3.1 should not match 3.12 or 3.10.\"\"\"\n        mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS)\n        with pytest.raises(RuntimeError, match=\"No Python 3.1.x found\"):\n            _resolve_python_version(\"https://example.com/release\", \"3.1\")\n\n\nclass TestDownloadStandalonePython:\n    @patch(\"comfy_cli.standalone.download_url\")\n    @patch(\"comfy_cli.standalone.requests.get\")\n    def test_minor_version_triggers_resolution(self, mock_get, mock_download):\n        \"\"\"When version is a minor version (X.Y), it should resolve the patch.\"\"\"\n        mock_get.side_effect = [\n            _mock_response('{\"tag\": \"20260310\", \"asset_url_prefix\": \"https://example.com/release\"}'),\n            _mock_response(SAMPLE_SHA256SUMS),\n        ]\n        mock_download.return_value = \"python.tar.gz\"\n\n        download_standalone_python(platform=\"linux\", proc=\"x86_64\", version=\"3.12\")\n\n        # Should have fetched latest-release.json and SHA256SUMS\n        assert mock_get.call_count == 2\n        # Download URL should contain resolved version\n        call_args = mock_download.call_args\n        assert \"3.12.13\" in call_args[1].get(\"url\", \"\") or \"3.12.13\" in str(call_args)\n\n    @patch(\"comfy_cli.standalone.download_url\")\n    @patch(\"comfy_cli.standalone.requests.get\")\n    def test_full_version_skips_resolution(self, mock_get, mock_download):\n        \"\"\"When version is a full version (X.Y.Z), no resolution needed.\"\"\"\n        mock_get.return_value = _mock_response('{\"tag\": \"20260310\", \"asset_url_prefix\": \"https://example.com/release\"}')\n        mock_download.return_value = \"python.tar.gz\"\n\n        download_standalone_python(platform=\"linux\", proc=\"x86_64\", version=\"3.12.13\")\n\n        # Should have fetched only latest-release.json, not SHA256SUMS\n        assert mock_get.call_count == 1\n\n\n_require_network = pytest.mark.skipif(\n    os.getenv(\"TEST_NETWORK\", \"false\").lower() != \"true\",\n    reason=\"Set TEST_NETWORK=true to run integration tests that hit the network\",\n)\n\n\n@_require_network\nclass TestResolveVersionIntegration:\n    \"\"\"Integration tests that hit the real python-build-standalone release endpoints.\"\"\"\n\n    def test_latest_release_json_is_reachable(self):\n        response = requests.get(_latest_release_json_url)\n        assert response.status_code == 200\n        data = response.json()\n        assert \"tag\" in data\n        assert \"asset_url_prefix\" in data\n        # tag should be a date string like \"20260310\"\n        assert re.fullmatch(r\"\\d{8}\", data[\"tag\"]), f\"unexpected tag format: {data['tag']}\"\n\n    def test_resolve_312_from_real_release(self):\n        response = requests.get(_latest_release_json_url)\n        data = response.json()\n        asset_url_prefix = data[\"asset_url_prefix\"]\n\n        version = _resolve_python_version(asset_url_prefix, \"3.12\")\n\n        # Should be a valid 3.12.x version\n        assert re.fullmatch(r\"3\\.12\\.\\d+\", version), f\"unexpected version: {version}\"\n\n    def test_sha256sums_contains_expected_platforms(self):\n        \"\"\"Verify the platforms we use in _platform_targets actually exist in the release.\"\"\"\n        response = requests.get(_latest_release_json_url)\n        data = response.json()\n        asset_url_prefix = data[\"asset_url_prefix\"]\n\n        sha256sums_url = f\"{asset_url_prefix}/SHA256SUMS\"\n        sha_response = requests.get(sha256sums_url)\n        assert sha_response.status_code == 200\n\n        content = sha_response.text\n        expected_targets = [\n            \"aarch64-apple-darwin\",\n            \"x86_64-apple-darwin\",\n            \"x86_64_v3-unknown-linux-gnu\",\n            \"x86_64-pc-windows-msvc\",\n        ]\n        for target in expected_targets:\n            assert target in content, f\"platform target '{target}' not found in SHA256SUMS\"\n"
  },
  {
    "path": "tests/comfy_cli/test_tracking.py",
    "content": "from unittest.mock import MagicMock, patch\n\nimport pytest\n\nfrom comfy_cli import constants\nfrom comfy_cli.config_manager import ConfigManager\n\n# Unwrap the singleton to get fresh ConfigManager instances per test.\n_ConfigManagerCls = ConfigManager.__closure__[0].cell_contents\n\n\n@pytest.fixture\ndef tracking_module(tmp_path):\n    \"\"\"Yield comfy_cli.tracking with a fresh tmp-path ConfigManager and a mocked Mixpanel client.\"\"\"\n    config_dir = tmp_path / \"comfy-cli\"\n    config_dir.mkdir()\n    with patch.object(_ConfigManagerCls, \"get_config_path\", return_value=str(config_dir)):\n        cfg = _ConfigManagerCls()\n\n    import comfy_cli.tracking as tracking_mod\n\n    with (\n        patch.object(tracking_mod, \"config_manager\", cfg),\n        patch.object(tracking_mod, \"user_id\", None),\n        patch.object(tracking_mod, \"cli_version\", \"test-cli-version\"),\n        patch.object(tracking_mod, \"tracing_id\", \"test-tracing-id\"),\n        patch.object(tracking_mod, \"mp\", MagicMock()),\n    ):\n        yield tracking_mod\n\n\nclass TestTrackEvent:\n    def test_short_circuits_when_disabled(self, tracking_module):\n        tracking_module.config_manager.set(constants.CONFIG_KEY_ENABLE_TRACKING, \"False\")\n        tracking_module.track_event(\"some_event\")\n        tracking_module.mp.track.assert_not_called()\n\n    def test_short_circuits_when_not_configured(self, tracking_module):\n        tracking_module.track_event(\"some_event\")\n        tracking_module.mp.track.assert_not_called()\n\n    def test_fires_when_enabled(self, tracking_module):\n        tracking_module.config_manager.set(constants.CONFIG_KEY_ENABLE_TRACKING, \"True\")\n        tracking_module.track_event(\"some_event\", {\"k\": \"v\"})\n        tracking_module.mp.track.assert_called_once()\n        _, kwargs = tracking_module.mp.track.call_args\n        assert kwargs[\"event_name\"] == \"some_event\"\n        assert kwargs[\"properties\"][\"k\"] == \"v\"\n        assert \"cli_version\" in kwargs[\"properties\"]\n        assert \"tracing_id\" in kwargs[\"properties\"]\n\n    def test_properties_default_to_empty_dict(self, tracking_module):\n        tracking_module.config_manager.set(constants.CONFIG_KEY_ENABLE_TRACKING, \"True\")\n        tracking_module.track_event(\"some_event\")\n        tracking_module.mp.track.assert_called_once()\n        _, kwargs = tracking_module.mp.track.call_args\n        assert set(kwargs[\"properties\"].keys()) == {\"cli_version\", \"tracing_id\"}\n\n    def test_swallows_mixpanel_errors(self, tracking_module):\n        tracking_module.config_manager.set(constants.CONFIG_KEY_ENABLE_TRACKING, \"True\")\n        tracking_module.mp.track.side_effect = RuntimeError(\"boom\")\n        tracking_module.track_event(\"some_event\")\n        tracking_module.mp.track.assert_called_once()\n\n\nclass TestTrackCommandRedaction:\n    \"\"\"track_command must redact secret-bearing kwargs before they reach the tracking system.\"\"\"\n\n    def test_api_key_value_is_redacted(self, tracking_module):\n        tracking_module.config_manager.set(constants.CONFIG_KEY_ENABLE_TRACKING, \"True\")\n\n        @tracking_module.track_command()\n        def some_cmd(workflow, api_key=None):\n            return None\n\n        some_cmd(workflow=\"wf.json\", api_key=\"sk-supersecret\")\n\n        tracking_module.mp.track.assert_called_once()\n        _, kwargs = tracking_module.mp.track.call_args\n        props = kwargs[\"properties\"]\n        assert props[\"api_key\"] == \"<redacted>\"\n        assert props[\"workflow\"] == \"wf.json\"\n        assert \"sk-supersecret\" not in str(props)\n\n    def test_api_key_none_stays_none(self, tracking_module):\n        # When the user didn't pass --api-key (or set $COMFY_API_KEY), we still\n        # want to be able to see in the analytics that it was absent — not a\n        # \"<redacted>\" sentinel that would imply they did pass one.\n        tracking_module.config_manager.set(constants.CONFIG_KEY_ENABLE_TRACKING, \"True\")\n\n        @tracking_module.track_command()\n        def some_cmd(workflow, api_key=None):\n            return None\n\n        some_cmd(workflow=\"wf.json\", api_key=None)\n\n        _, kwargs = tracking_module.mp.track.call_args\n        assert kwargs[\"properties\"][\"api_key\"] is None\n\n\nclass TestInitTrackingRoundTrip:\n    \"\"\"End-to-end: init_tracking() writes the string \"False\"/\"True\", and track_event honors it.\n\n    Regression for a prior bug where track_event used config_manager.get(), which returned\n    the raw string \"False\" (a truthy value), so disabling via this code path had no effect.\n    \"\"\"\n\n    def test_disable_is_respected_by_track_event(self, tracking_module):\n        tracking_module.init_tracking(False)\n        tracking_module.track_event(\"some_event\")\n        tracking_module.mp.track.assert_not_called()\n\n    def test_enable_is_respected_by_track_event(self, tracking_module):\n        tracking_module.init_tracking(True)\n        tracking_module.mp.track.reset_mock()\n        tracking_module.track_event(\"some_event\")\n        tracking_module.mp.track.assert_called_once()\n\n    def test_disable_persists_as_parseable_bool(self, tracking_module):\n        tracking_module.init_tracking(False)\n        assert tracking_module.config_manager.get_bool(constants.CONFIG_KEY_ENABLE_TRACKING) is False\n\n    def test_enable_generates_user_id(self, tracking_module):\n        assert tracking_module.config_manager.get(constants.CONFIG_KEY_USER_ID) is None\n        tracking_module.init_tracking(True)\n        generated_user_id = tracking_module.config_manager.get(constants.CONFIG_KEY_USER_ID)\n        assert generated_user_id is not None\n        assert tracking_module.user_id == generated_user_id\n        _, kwargs = tracking_module.mp.track.call_args\n        assert kwargs[\"distinct_id\"] == generated_user_id\n\n    def test_disable_does_not_generate_user_id(self, tracking_module):\n        tracking_module.init_tracking(False)\n        assert tracking_module.config_manager.get(constants.CONFIG_KEY_USER_ID) is None\n\n    def test_install_event_fires_once_across_calls(self, tracking_module):\n        tracking_module.init_tracking(True)\n        assert tracking_module.mp.track.call_count == 1\n        tracking_module.init_tracking(True)\n        assert tracking_module.mp.track.call_count == 1\n"
  },
  {
    "path": "tests/comfy_cli/test_ui.py",
    "content": "import io\n\nfrom rich.console import Console\n\nimport comfy_cli.ui as ui_module\nfrom comfy_cli.ui import display_error_message\n\n\ndef _capture(fn, *args, **kwargs):\n    \"\"\"Run fn against a Console that writes to an in-memory buffer, return the output string.\"\"\"\n    buf = io.StringIO()\n    original = ui_module.console\n    ui_module.console = Console(file=buf, force_terminal=False, markup=True)\n    try:\n        fn(*args, **kwargs)\n    finally:\n        ui_module.console = original\n    return buf.getvalue()\n\n\nclass TestDisplayErrorMessageMarkup:\n    \"\"\"display_error_message must accept any string content without raising rich.errors.MarkupError\n    or silently stripping bracketed substrings. Error messages can contain server-controlled text\n    (e.g. JSON body echoed into a DownloadException) which may include arbitrary [ and ] chars.\"\"\"\n\n    def test_plain_message_rendered(self):\n        out = _capture(display_error_message, \"plain error\")\n        assert \"plain error\" in out\n\n    def test_closing_tag_alone_does_not_crash(self):\n        # Prior to the fix this raised rich.errors.MarkupError on console.print.\n        out = _capture(display_error_message, \"error with [/] in the middle\")\n        assert \"[/]\" in out\n\n    def test_bracketed_substring_preserved(self):\n        # Prior to the fix \"[id]\" was consumed as an unknown style and stripped.\n        out = _capture(display_error_message, \"URL /path/[id]/resource not found\")\n        assert \"[id]\" in out\n        assert \"/path/[id]/resource\" in out\n\n    def test_multiple_markup_like_tokens(self):\n        out = _capture(display_error_message, \"server said [redacted] at [host]:[port]\")\n        assert \"[redacted]\" in out\n        assert \"[host]\" in out\n        assert \"[port]\" in out\n\n    def test_unbalanced_opening_bracket(self):\n        out = _capture(display_error_message, \"unbalanced [tag without close\")\n        assert \"[tag without close\" in out\n"
  },
  {
    "path": "tests/comfy_cli/test_update.py",
    "content": "from unittest.mock import MagicMock, patch\n\nimport requests\n\nfrom comfy_cli.update import check_for_newer_pypi_version, check_for_updates\n\n\ndef _mock_pypi_response(latest_version):\n    mock_resp = MagicMock()\n    mock_resp.status_code = 200\n    mock_resp.json.return_value = {\"info\": {\"version\": latest_version}}\n    return mock_resp\n\n\nclass TestCheckForNewerPypiVersion:\n    @patch(\"comfy_cli.update.requests.get\")\n    def test_newer_version_available(self, mock_get):\n        mock_get.return_value = _mock_pypi_response(\"99.0.0\")\n        has_newer, ver = check_for_newer_pypi_version(\"comfy-cli\", \"1.0.0\")\n        assert has_newer is True\n        assert ver == \"99.0.0\"\n\n    @patch(\"comfy_cli.update.requests.get\")\n    def test_no_update_when_current(self, mock_get):\n        mock_get.return_value = _mock_pypi_response(\"1.0.0\")\n        has_newer, ver = check_for_newer_pypi_version(\"comfy-cli\", \"1.0.0\")\n        assert has_newer is False\n        assert ver == \"1.0.0\"\n\n    @patch(\"comfy_cli.update.requests.get\")\n    def test_network_failure_returns_false(self, mock_get):\n        mock_get.side_effect = requests.Timeout(\"connection timed out\")\n        has_newer, ver = check_for_newer_pypi_version(\"comfy-cli\", \"1.0.0\")\n        assert has_newer is False\n        assert ver == \"1.0.0\"\n\n    @patch(\"comfy_cli.update.requests.get\")\n    def test_timeout_value_is_passed(self, mock_get):\n        mock_get.return_value = _mock_pypi_response(\"1.0.0\")\n        check_for_newer_pypi_version(\"comfy-cli\", \"1.0.0\")\n        mock_get.assert_called_once_with(\"https://pypi.org/pypi/comfy-cli/json\", timeout=5)\n\n\nclass TestCheckForUpdates:\n    @patch(\"comfy_cli.update.notify_update\")\n    @patch(\"comfy_cli.update.get_version_from_pyproject\", return_value=\"1.0.0\")\n    @patch(\"comfy_cli.update.requests.get\")\n    def test_notifies_when_update_available(self, mock_get, _mock_ver, mock_notify):\n        mock_get.return_value = _mock_pypi_response(\"2.0.0\")\n        check_for_updates()\n        mock_notify.assert_called_once_with(\"1.0.0\", \"2.0.0\")\n\n    @patch(\"comfy_cli.update.notify_update\")\n    @patch(\"comfy_cli.update.get_version_from_pyproject\", return_value=\"1.0.0\")\n    @patch(\"comfy_cli.update.requests.get\")\n    def test_no_notification_on_network_error(self, mock_get, _mock_ver, mock_notify):\n        mock_get.side_effect = requests.ConnectionError(\"offline\")\n        check_for_updates()\n        mock_notify.assert_not_called()\n"
  },
  {
    "path": "tests/comfy_cli/test_utils.py",
    "content": "import io\nfrom unittest.mock import MagicMock, patch\n\nfrom comfy_cli.utils import create_tarball, download_url, extract_tarball\n\n\nclass _FakeRaw(io.BytesIO):\n    \"\"\"BytesIO that accepts decode_content kwarg like urllib3 responses.\n\n    The production code does ``response.raw.read = functools.partial(\n    response.raw.read, decode_content=True)`` which monkey-patches the\n    read method.  A plain BytesIO would blow up because its read() does\n    not accept that kwarg.\n    \"\"\"\n\n    def read(self, amt=-1, decode_content=False):\n        return super().read(amt)\n\n\nclass TestDownloadUrl:\n    @patch(\"comfy_cli.utils.requests.get\")\n    def test_writes_file(self, mock_get, tmp_path):\n        content = b\"file contents here\"\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n        mock_response.headers = {\"Content-Length\": str(len(content))}\n        mock_response.raw = _FakeRaw(content)\n        mock_get.return_value = mock_response\n\n        result = download_url(\"http://example.com/f.bin\", \"f.bin\", cwd=tmp_path, show_progress=False)\n        assert result == tmp_path / \"f.bin\"\n        assert (tmp_path / \"f.bin\").read_bytes() == content\n\n\nclass TestTarballRoundTrip:\n    def test_create_and_extract(self, tmp_path, monkeypatch):\n        monkeypatch.chdir(tmp_path)\n\n        src = tmp_path / \"mydir\"\n        src.mkdir()\n        (src / \"hello.txt\").write_text(\"hello world\")\n        (src / \"sub\").mkdir()\n        (src / \"sub\" / \"nested.txt\").write_text(\"nested content\")\n\n        tarball = tmp_path / \"mydir.tgz\"\n        with patch(\"comfy_cli.utils.Live\"):\n            create_tarball(src, tarball, cwd=tmp_path)\n        assert tarball.exists()\n\n        dest = tmp_path / \"extracted\"\n        with patch(\"comfy_cli.utils.Live\"):\n            extract_tarball(tarball, dest)\n\n        assert (dest / \"hello.txt\").read_text() == \"hello world\"\n        assert (dest / \"sub\" / \"nested.txt\").read_text() == \"nested content\"\n"
  },
  {
    "path": "tests/comfy_cli/test_workflow_to_api.py",
    "content": "\"\"\"Unit tests for the UI -> API workflow converter.\"\"\"\n\nimport json\nimport random\nfrom pathlib import Path\nfrom unittest.mock import patch\n\nimport pytest\n\nfrom comfy_cli.workflow_to_api import (\n    WorkflowConversionError,\n    convert_ui_to_api,\n    is_api_format,\n    is_subgraph_uuid,\n    process_dynamic_prompt,\n)\n\nFIXTURES = Path(__file__).parent / \"fixtures\"\n\n\n# ---------------------------------------------------------------------------\n# Reusable fixtures: a tiny `/object_info` covering the schemas the tests use.\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture\ndef object_info():\n    return {\n        \"EmptyLatentImage\": {\n            \"input\": {\n                \"required\": {\n                    \"width\": [\"INT\", {\"default\": 512}],\n                    \"height\": [\"INT\", {\"default\": 512}],\n                    \"batch_size\": [\"INT\", {\"default\": 1}],\n                }\n            },\n            \"input_order\": {\"required\": [\"width\", \"height\", \"batch_size\"]},\n            \"output_node\": False,\n            \"output\": [\"LATENT\"],\n            \"display_name\": \"Empty Latent Image\",\n        },\n        \"KSampler\": {\n            \"input\": {\n                \"required\": {\n                    \"model\": [\"MODEL\"],\n                    \"seed\": [\"INT\", {\"default\": 0, \"control_after_generate\": True}],\n                    \"steps\": [\"INT\", {\"default\": 20}],\n                    \"cfg\": [\"FLOAT\", {\"default\": 8.0}],\n                    \"sampler_name\": [[\"euler\", \"ddim\"], {\"default\": \"euler\"}],\n                    \"scheduler\": [[\"normal\", \"karras\"], {\"default\": \"normal\"}],\n                    \"positive\": [\"CONDITIONING\"],\n                    \"negative\": [\"CONDITIONING\"],\n                    \"latent_image\": [\"LATENT\"],\n                    \"denoise\": [\"FLOAT\", {\"default\": 1.0}],\n                }\n            },\n            \"input_order\": {\n                \"required\": [\n                    \"model\",\n                    \"seed\",\n                    \"steps\",\n                    \"cfg\",\n                    \"sampler_name\",\n                    \"scheduler\",\n                    \"positive\",\n                    \"negative\",\n                    \"latent_image\",\n                    \"denoise\",\n                ]\n            },\n            \"output_node\": False,\n            \"output\": [\"LATENT\"],\n            \"display_name\": \"KSampler\",\n        },\n        \"PreviewImage\": {\n            \"input\": {\"required\": {\"images\": [\"IMAGE\"]}},\n            \"input_order\": {\"required\": [\"images\"]},\n            \"output_node\": True,\n            \"output\": [],\n            \"display_name\": \"Preview Image\",\n        },\n        \"CLIPTextEncode\": {\n            \"input\": {\n                \"required\": {\n                    \"text\": [\"STRING\", {\"multiline\": True}],\n                    \"clip\": [\"CLIP\"],\n                }\n            },\n            \"input_order\": {\"required\": [\"text\", \"clip\"]},\n            \"output_node\": False,\n            \"output\": [\"CONDITIONING\"],\n            \"display_name\": \"CLIP Text Encode\",\n        },\n        \"VAEDecode\": {\n            \"input\": {\"required\": {\"samples\": [\"LATENT\"], \"vae\": [\"VAE\"]}},\n            \"input_order\": {\"required\": [\"samples\", \"vae\"]},\n            \"output_node\": False,\n            \"output\": [\"IMAGE\"],\n            \"display_name\": \"VAE Decode\",\n        },\n    }\n\n\ndef _node(node_id, node_type, *, inputs=None, outputs=None, widgets=None, mode=0, **extra):\n    \"\"\"Helper to build a minimal UI node entry.\"\"\"\n    n = {\n        \"id\": node_id,\n        \"type\": node_type,\n        \"inputs\": inputs or [],\n        \"outputs\": outputs or [],\n        \"mode\": mode,\n    }\n    if widgets is not None:\n        n[\"widgets_values\"] = widgets\n    n.update(extra)\n    return n\n\n\n# ---------------------------------------------------------------------------\n# Format detection\n# ---------------------------------------------------------------------------\n\n\nclass TestIsApiFormat:\n    def test_recognizes_api(self):\n        assert is_api_format({\"1\": {\"class_type\": \"Foo\", \"inputs\": {}}})\n\n    def test_ui_is_not_api(self):\n        assert not is_api_format({\"nodes\": [], \"links\": []})\n\n    def test_non_dict_is_not_api(self):\n        assert not is_api_format([])\n        assert not is_api_format(\"string\")\n        assert not is_api_format(None)\n\n    def test_empty_dict_is_not_api(self):\n        assert not is_api_format({})\n\n    def test_metadata_only_is_not_api(self):\n        # Keys exist but none has a class_type\n        assert not is_api_format({\"prompt\": \"x\", \"client_id\": \"y\"})\n\n\nclass TestIsSubgraphUuid:\n    def test_real_uuid(self):\n        assert is_subgraph_uuid(\"b43bb7e6-178c-4f1a-b014-ac4d6a50fca2\")\n\n    def test_class_name_is_not_uuid(self):\n        assert not is_subgraph_uuid(\"ImageScaleToTotalPixels\")\n\n    def test_wrong_length(self):\n        assert not is_subgraph_uuid(\"b43bb7e6-178c-4f1a-b014-ac4d6a50fc\")\n\n    def test_wrong_dash_count(self):\n        assert not is_subgraph_uuid(\"b43bb7e6_178c_4f1a_b014_ac4d6a50fca2x\")\n\n    def test_non_string(self):\n        assert not is_subgraph_uuid(123)\n        assert not is_subgraph_uuid(None)\n\n\n# ---------------------------------------------------------------------------\n# Core conversion: end-to-end shape\n# ---------------------------------------------------------------------------\n\n\nclass TestConvertCore:\n    def test_already_api_is_returned_unchanged(self, object_info):\n        api = {\"1\": {\"class_type\": \"EmptyLatentImage\", \"inputs\": {}, \"_meta\": {\"title\": \"x\"}}}\n        assert convert_ui_to_api(api, object_info) == api\n\n    def test_minimal_workflow(self, object_info):\n        # EmptyLatentImage(1) -> PreviewImage(2): mark via the VAEDecode chain\n        # is overkill — just connect a single link.\n        workflow = {\n            \"nodes\": [\n                _node(\n                    1,\n                    \"EmptyLatentImage\",\n                    outputs=[{\"name\": \"LATENT\", \"type\": \"LATENT\", \"links\": [100]}],\n                    widgets=[512, 512, 1],\n                ),\n                _node(\n                    2,\n                    \"PreviewImage\",\n                    inputs=[{\"name\": \"images\", \"link\": 100}],\n                    outputs=[],\n                ),\n            ],\n            \"links\": [[100, 1, 0, 2, 0, \"IMAGE\"]],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        assert set(result) == {\"1\", \"2\"}\n        assert result[\"1\"][\"class_type\"] == \"EmptyLatentImage\"\n        assert result[\"1\"][\"inputs\"] == {\"width\": 512, \"height\": 512, \"batch_size\": 1}\n        assert result[\"2\"][\"class_type\"] == \"PreviewImage\"\n        assert result[\"2\"][\"inputs\"] == {\"images\": [\"1\", 0]}\n\n    def test_input_order_follows_schema(self, object_info):\n        # KSampler should emit widget values first in schema order, then link inputs.\n        # Producer nodes use EmptyLatentImage stand-ins for all three connection\n        # inputs; the converter doesn't typecheck, so this is enough to keep the\n        # links from being treated as orphans.\n        workflow = {\n            \"nodes\": [\n                _node(\n                    1,\n                    \"KSampler\",\n                    inputs=[\n                        {\"name\": \"model\", \"link\": 10},\n                        {\"name\": \"positive\", \"link\": 11},\n                        {\"name\": \"negative\", \"link\": 12},\n                        {\"name\": \"latent_image\", \"link\": 13},\n                    ],\n                    outputs=[{\"name\": \"LATENT\", \"type\": \"LATENT\", \"links\": [20]}],\n                    widgets=[42, \"randomize\", 20, 8.0, \"euler\", \"normal\", 1.0],\n                ),\n                _node(2, \"EmptyLatentImage\", outputs=[{\"links\": [13]}], widgets=[512, 512, 1]),\n                _node(91, \"EmptyLatentImage\", outputs=[{\"links\": [10]}], widgets=[64, 64, 1]),\n                _node(92, \"EmptyLatentImage\", outputs=[{\"links\": [11]}], widgets=[64, 64, 1]),\n                _node(93, \"EmptyLatentImage\", outputs=[{\"links\": [12]}], widgets=[64, 64, 1]),\n                _node(3, \"PreviewImage\", inputs=[{\"name\": \"images\", \"link\": 20}], outputs=[]),\n            ],\n            \"links\": [\n                [10, 91, 0, 1, 0, \"MODEL\"],\n                [11, 92, 0, 1, 6, \"CONDITIONING\"],\n                [12, 93, 0, 1, 7, \"CONDITIONING\"],\n                [13, 2, 0, 1, 8, \"LATENT\"],\n                [20, 1, 0, 3, 0, \"LATENT\"],\n            ],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        inputs = result[\"1\"][\"inputs\"]\n        # All widget values come before all link inputs, both in schema order.\n        keys = list(inputs)\n        widget_keys = [\"seed\", \"steps\", \"cfg\", \"sampler_name\", \"scheduler\", \"denoise\"]\n        link_keys = [\"model\", \"positive\", \"negative\", \"latent_image\"]\n        # Each group should appear in this order.\n        assert [k for k in keys if k in widget_keys] == widget_keys\n        assert [k for k in keys if k in link_keys] == link_keys\n        # Widgets come before links overall\n        assert keys.index(\"denoise\") < keys.index(\"model\")\n        # Control-after-generate \"randomize\" was stripped from after seed\n        assert inputs[\"seed\"] == 42\n\n    def test_unknown_node_type_uses_class_name_as_title(self, object_info):\n        workflow = {\n            \"nodes\": [\n                _node(\n                    1,\n                    \"TotallyUnknownNode\",\n                    outputs=[{\"links\": [1]}],\n                ),\n                _node(\n                    2,\n                    \"PreviewImage\",\n                    inputs=[{\"name\": \"images\", \"link\": 1}],\n                    outputs=[],\n                ),\n            ],\n            \"links\": [[1, 1, 0, 2, 0, \"IMAGE\"]],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        assert result[\"1\"][\"_meta\"][\"title\"] == \"TotallyUnknownNode\"\n\n    def test_node_title_overrides_display_name(self, object_info):\n        workflow = {\n            \"nodes\": [\n                _node(\n                    1,\n                    \"EmptyLatentImage\",\n                    outputs=[{\"links\": [1]}],\n                    widgets=[512, 512, 1],\n                    title=\"My Custom Title\",\n                ),\n                _node(2, \"PreviewImage\", inputs=[{\"name\": \"images\", \"link\": 1}]),\n            ],\n            \"links\": [[1, 1, 0, 2, 0, \"LATENT\"]],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        assert result[\"1\"][\"_meta\"][\"title\"] == \"My Custom Title\"\n\n    def test_invalid_workflow_raises(self, object_info):\n        with pytest.raises(WorkflowConversionError):\n            convert_ui_to_api({\"nodes\": \"not a list\"}, object_info)\n\n\n# ---------------------------------------------------------------------------\n# Special node types\n# ---------------------------------------------------------------------------\n\n\nclass TestSpecialNodes:\n    def test_primitive_node_inlines_value(self, object_info):\n        # PrimitiveNode(1, value=1024) -> EmptyLatentImage(2).width\n        workflow = {\n            \"nodes\": [\n                _node(\n                    1,\n                    \"PrimitiveNode\",\n                    outputs=[{\"links\": [5]}],\n                    widgets=[1024, \"fixed\"],\n                ),\n                _node(\n                    2,\n                    \"EmptyLatentImage\",\n                    inputs=[{\"name\": \"width\", \"link\": 5}],\n                    outputs=[{\"links\": [99]}],\n                    widgets=[1024, 512, 1],\n                ),\n                _node(3, \"PreviewImage\", inputs=[{\"name\": \"images\", \"link\": 99}]),\n            ],\n            \"links\": [\n                [5, 1, 0, 2, 0, \"INT\"],\n                [99, 2, 0, 3, 0, \"LATENT\"],\n            ],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        assert \"1\" not in result  # PrimitiveNode excluded\n        # The value flowed from primitive into the consuming node's inputs\n        assert result[\"2\"][\"inputs\"][\"width\"] == 1024\n\n    def test_reroute_is_transparent(self, object_info):\n        workflow = {\n            \"nodes\": [\n                _node(1, \"EmptyLatentImage\", outputs=[{\"links\": [1]}], widgets=[512, 512, 1]),\n                _node(\n                    99,\n                    \"Reroute\",\n                    inputs=[{\"name\": \"in\", \"link\": 1}],\n                    outputs=[{\"links\": [2]}],\n                ),\n                _node(2, \"PreviewImage\", inputs=[{\"name\": \"images\", \"link\": 2}]),\n            ],\n            \"links\": [\n                [1, 1, 0, 99, 0, \"LATENT\"],\n                [2, 99, 0, 2, 0, \"LATENT\"],\n            ],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        assert \"99\" not in result  # Reroute excluded\n        # The reroute's downstream consumer points at the reroute's source\n        assert result[\"2\"][\"inputs\"][\"images\"] == [\"1\", 0]\n\n    def test_get_set_node_pair(self, object_info):\n        # SetNode publishes node 1's output as variable \"myvar\"\n        # GetNode reads \"myvar\" and forwards to node 2\n        workflow = {\n            \"nodes\": [\n                _node(1, \"EmptyLatentImage\", outputs=[{\"links\": [10]}], widgets=[512, 512, 1]),\n                _node(\n                    20,\n                    \"SetNode\",\n                    inputs=[{\"name\": \"value\", \"link\": 10}],\n                    widgets=[\"myvar\"],\n                ),\n                _node(\n                    21,\n                    \"GetNode\",\n                    outputs=[{\"links\": [11]}],\n                    widgets=[\"myvar\"],\n                ),\n                _node(2, \"PreviewImage\", inputs=[{\"name\": \"images\", \"link\": 11}]),\n            ],\n            \"links\": [\n                [10, 1, 0, 20, 0, \"LATENT\"],\n                [11, 21, 0, 2, 0, \"LATENT\"],\n            ],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        assert \"20\" not in result  # SetNode excluded\n        assert \"21\" not in result  # GetNode excluded\n        assert result[\"2\"][\"inputs\"][\"images\"] == [\"1\", 0]\n\n    def test_muted_node_is_excluded(self, object_info):\n        workflow = {\n            \"nodes\": [\n                _node(1, \"EmptyLatentImage\", outputs=[{\"links\": [1]}], widgets=[512, 512, 1]),\n                _node(\n                    2,\n                    \"PreviewImage\",\n                    inputs=[{\"name\": \"images\", \"link\": 1}],\n                    mode=2,  # muted\n                ),\n            ],\n            \"links\": [[1, 1, 0, 2, 0, \"LATENT\"]],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        # Both 1 (no downstream consumer after 2 is muted, and not OUTPUT_NODE\n        # because it has no connected output) and 2 (muted) are excluded.\n        assert \"2\" not in result\n\n    def test_bypassed_node_passes_through(self, object_info):\n        # 1 -> 99 (bypassed) -> 2; result should connect 1 directly to 2.\n        workflow = {\n            \"nodes\": [\n                _node(1, \"EmptyLatentImage\", outputs=[{\"links\": [1]}], widgets=[512, 512, 1]),\n                _node(\n                    99,\n                    \"VAEDecode\",  # any passthrough-able node will do\n                    inputs=[\n                        {\"name\": \"samples\", \"type\": \"LATENT\", \"link\": 1},\n                        {\"name\": \"vae\", \"type\": \"VAE\", \"link\": None},\n                    ],\n                    outputs=[{\"name\": \"IMAGE\", \"type\": \"LATENT\", \"links\": [2]}],\n                    mode=4,  # bypassed\n                ),\n                _node(2, \"PreviewImage\", inputs=[{\"name\": \"images\", \"link\": 2}]),\n            ],\n            \"links\": [\n                [1, 1, 0, 99, 0, \"LATENT\"],\n                [2, 99, 0, 2, 0, \"LATENT\"],\n            ],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        assert \"99\" not in result  # bypassed\n        assert result[\"2\"][\"inputs\"][\"images\"] == [\"1\", 0]\n\n    def test_load_image_output_excluded(self, object_info):\n        # LoadImageOutput is the only hardcoded UI-only exclusion.\n        workflow = {\n            \"nodes\": [\n                _node(\n                    1,\n                    \"LoadImageOutput\",\n                    outputs=[{\"links\": [1]}],\n                    widgets=[\"pic.png\"],\n                ),\n                _node(\n                    2,\n                    \"PreviewImage\",\n                    inputs=[{\"name\": \"images\", \"link\": 1}],\n                ),\n            ],\n            \"links\": [[1, 1, 0, 2, 0, \"IMAGE\"]],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        assert \"1\" not in result\n\n    def test_note_node_excluded(self, object_info):\n        workflow = {\n            \"nodes\": [\n                _node(1, \"Note\", widgets=[\"just text\"]),\n                _node(2, \"EmptyLatentImage\", outputs=[{\"links\": []}], widgets=[512, 512, 1]),\n            ],\n            \"links\": [],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        assert \"1\" not in result\n\n    def test_output_node_kept_even_without_outgoing_links(self, object_info):\n        workflow = {\n            \"nodes\": [\n                _node(1, \"EmptyLatentImage\", outputs=[{\"links\": [1]}], widgets=[512, 512, 1]),\n                # PreviewImage's `output_node` is True in the schema → kept.\n                _node(2, \"PreviewImage\", inputs=[{\"name\": \"images\", \"link\": 1}], outputs=[]),\n            ],\n            \"links\": [[1, 1, 0, 2, 0, \"IMAGE\"]],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        assert \"2\" in result\n\n    def test_unwired_node_still_emitted(self, object_info):\n        # A node with no connected outputs and no schema-declared output_node\n        # used to be dropped by an aggressive \"dead-branch\" heuristic. The\n        # frontend's graphToPrompt() emits every non-virtual, non-muted,\n        # non-bypassed node regardless — the executor only runs nodes\n        # reachable from sinks, so leftover unwired nodes are harmless.\n        # See cloud-mcp-server/src/converter/nodeFilter.ts shouldIncludeInOutput.\n        workflow = {\n            \"nodes\": [\n                _node(\n                    99,\n                    \"EmptyLatentImage\",\n                    outputs=[{\"links\": []}],\n                    widgets=[64, 64, 1],\n                ),\n            ],\n            \"links\": [],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        assert \"99\" in result\n        assert result[\"99\"][\"class_type\"] == \"EmptyLatentImage\"\n        assert result[\"99\"][\"inputs\"] == {\"width\": 64, \"height\": 64, \"batch_size\": 1}\n\n    def test_unwired_load_node_still_emitted(self, object_info):\n        # Real cloud-mcp regression: a saved workflow has a LoadAudio that the\n        # user added but didn't yet wire to anything. The frontend's\n        # graphToPrompt() emits it; we used to drop it via dead-branch\n        # exclusion, losing the node entirely from the API output.\n        load_audio_schema = {\n            \"LoadAudio\": {\n                \"input\": {\n                    \"required\": {\n                        \"audio\": [[\"song.mp3\"], {}],\n                    }\n                },\n                \"input_order\": {\"required\": [\"audio\"]},\n                \"output_node\": False,\n                \"output\": [\"AUDIO\"],\n                \"display_name\": \"Load Audio\",\n            }\n        }\n        workflow = {\n            \"nodes\": [\n                {\n                    \"id\": 1,\n                    \"type\": \"LoadAudio\",\n                    \"inputs\": [],\n                    \"outputs\": [{\"name\": \"AUDIO\", \"type\": \"AUDIO\", \"links\": None}],\n                    \"widgets_values\": [\"song.mp3\"],\n                    \"mode\": 0,\n                },\n            ],\n            \"links\": [],\n        }\n        result = convert_ui_to_api(workflow, load_audio_schema)\n        assert \"1\" in result\n        assert result[\"1\"][\"inputs\"] == {\"audio\": \"song.mp3\"}\n\n    def test_markdown_note_excluded(self, object_info):\n        # MarkdownNote is a UI-only documentation node with no Python class\n        # behind it. Must never appear in the API output even when not\n        # otherwise filtered out by dead-branch logic (which we no longer\n        # apply).\n        workflow = {\n            \"nodes\": [\n                _node(\n                    1,\n                    \"MarkdownNote\",\n                    outputs=[],\n                    widgets=[\"# Heading\\n\\nSome documentation\"],\n                ),\n                _node(2, \"EmptyLatentImage\", outputs=[{\"links\": [1]}], widgets=[512, 512, 1]),\n                _node(3, \"PreviewImage\", inputs=[{\"name\": \"images\", \"link\": 1}], outputs=[]),\n            ],\n            \"links\": [[1, 2, 0, 3, 0, \"IMAGE\"]],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        assert \"1\" not in result\n        assert {\"2\", \"3\"} <= set(result)\n\n\n# ---------------------------------------------------------------------------\n# Schema-aware behaviors\n# ---------------------------------------------------------------------------\n\n\nclass TestSchemaAwareBehavior:\n    def test_combo_value_normalized_case_insensitively(self, object_info):\n        workflow = {\n            \"nodes\": [\n                _node(\n                    1,\n                    \"KSampler\",\n                    inputs=[],\n                    outputs=[{\"links\": []}],\n                    widgets=[1, \"fixed\", 1, 1.0, \"EULER\", \"Normal\", 1.0],\n                ),\n                _node(2, \"PreviewImage\", inputs=[{\"name\": \"images\", \"link\": None}]),\n            ],\n            \"links\": [],\n        }\n        # KSampler with no inputs is not a viable workflow, but we just want the\n        # combo normalization assertion. Bypass the dead-branch exclusion by\n        # giving it a real downstream link.\n        workflow[\"nodes\"][0][\"outputs\"] = [{\"links\": [1]}]\n        workflow[\"nodes\"][1][\"inputs\"][0][\"link\"] = 1\n        workflow[\"links\"] = [[1, 1, 0, 2, 0, \"LATENT\"]]\n\n        result = convert_ui_to_api(workflow, object_info)\n        assert result[\"1\"][\"inputs\"][\"sampler_name\"] == \"euler\"  # normalized to lowercase\n        assert result[\"1\"][\"inputs\"][\"scheduler\"] == \"normal\"\n\n    def test_defaults_filled_when_widget_values_absent(self, object_info):\n        # Node with only one widget value; the others should come from schema defaults\n        # (object_info[\"EmptyLatentImage\"][\"input\"][\"required\"][\"height\"][\"default\"] = 512)\n        workflow = {\n            \"nodes\": [\n                _node(\n                    1,\n                    \"EmptyLatentImage\",\n                    outputs=[{\"links\": [1]}],\n                    widgets=[1024],  # only width supplied\n                ),\n                _node(2, \"PreviewImage\", inputs=[{\"name\": \"images\", \"link\": 1}]),\n            ],\n            \"links\": [[1, 1, 0, 2, 0, \"LATENT\"]],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        assert result[\"1\"][\"inputs\"][\"width\"] == 1024\n        assert result[\"1\"][\"inputs\"][\"height\"] == 512  # filled from schema default\n        assert result[\"1\"][\"inputs\"][\"batch_size\"] == 1\n\n\n# ---------------------------------------------------------------------------\n# Subgraph expansion\n# ---------------------------------------------------------------------------\n\n\nclass TestMalformedInputHardening:\n    \"\"\"The converter must never crash on a malformed workflow — only raise a\n    typed :class:`WorkflowConversionError` (or skip the offending pieces with a\n    log warning). The CLI wraps those into a clean exit; uncaught exceptions\n    would bubble up as a raw Python traceback, which is unacceptable for an\n    experimental feature.\n    \"\"\"\n\n    def test_rejects_non_dict_workflow(self, object_info):\n        with pytest.raises(WorkflowConversionError):\n            convert_ui_to_api(None, object_info)\n        with pytest.raises(WorkflowConversionError):\n            convert_ui_to_api(\"nope\", object_info)\n\n    def test_rejects_non_dict_object_info(self):\n        with pytest.raises(WorkflowConversionError):\n            convert_ui_to_api({\"nodes\": [], \"links\": []}, \"not a dict\")\n\n    def test_rejects_missing_nodes_or_links(self, object_info):\n        with pytest.raises(WorkflowConversionError):\n            convert_ui_to_api({}, object_info)\n        with pytest.raises(WorkflowConversionError):\n            convert_ui_to_api({\"nodes\": \"oops\", \"links\": []}, object_info)\n\n    def test_skips_non_dict_node_entries(self, object_info):\n        # A workflow with mixed garbage in the nodes list should still convert\n        # the well-formed nodes and ignore the rest.\n        workflow = {\n            \"nodes\": [\n                None,\n                42,\n                \"string\",\n                _node(1, \"EmptyLatentImage\", outputs=[{\"links\": [1]}], widgets=[512, 512, 1]),\n                _node(2, \"PreviewImage\", inputs=[{\"name\": \"images\", \"link\": 1}], outputs=[]),\n            ],\n            \"links\": [[1, 1, 0, 2, 0, \"IMAGE\"]],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        assert set(result) == {\"1\", \"2\"}\n\n    def test_tolerates_garbage_in_inputs_and_outputs(self, object_info):\n        # Outputs/inputs containing non-dict garbage shouldn't crash collection.\n        workflow = {\n            \"nodes\": [\n                {\n                    \"id\": 1,\n                    \"type\": \"EmptyLatentImage\",\n                    \"inputs\": [None, 42, {\"name\": \"x\", \"link\": None}],\n                    \"outputs\": [None, 42, {\"name\": \"LATENT\", \"links\": [1]}],\n                    \"widgets_values\": [512, 512, 1],\n                    \"mode\": 0,\n                },\n                _node(2, \"PreviewImage\", inputs=[{\"name\": \"images\", \"link\": 1}], outputs=[]),\n            ],\n            \"links\": [[1, 1, 2, 2, 0, \"IMAGE\"]],\n        }\n        # Should not raise.\n        result = convert_ui_to_api(workflow, object_info)\n        assert \"1\" in result\n        assert \"2\" in result\n\n    def test_tolerates_non_list_widgets_values(self, object_info):\n        workflow = {\n            \"nodes\": [\n                _node(1, \"EmptyLatentImage\", outputs=[{\"links\": [1]}]),  # no widgets at all\n                {\n                    \"id\": 2,\n                    \"type\": \"EmptyLatentImage\",\n                    \"outputs\": [{\"links\": [2]}],\n                    \"widgets_values\": 42,  # invalid: an int\n                    \"mode\": 0,\n                },\n                _node(3, \"PreviewImage\", inputs=[{\"name\": \"images\", \"link\": 2}], outputs=[]),\n            ],\n            \"links\": [[1, 1, 0, 3, 0, \"IMAGE\"], [2, 2, 0, 3, 0, \"IMAGE\"]],\n        }\n        # Should not raise; the node with int widgets_values just emits no widgets.\n        result = convert_ui_to_api(workflow, object_info)\n        assert \"2\" in result\n\n    def test_tolerates_non_numeric_slot_in_link(self, object_info):\n        # A bypass-time link with a string slot index should fall back to slot 0.\n        workflow = {\n            \"nodes\": [\n                _node(1, \"EmptyLatentImage\", outputs=[{\"links\": [1]}], widgets=[512, 512, 1]),\n                {\n                    \"id\": 99,\n                    \"type\": \"VAEDecode\",\n                    \"inputs\": [{\"name\": \"samples\", \"type\": \"LATENT\", \"link\": 1}],\n                    \"outputs\": [{\"name\": \"IMAGE\", \"type\": \"IMAGE\", \"links\": [2]}],\n                    \"mode\": 4,\n                },\n                _node(2, \"PreviewImage\", inputs=[{\"name\": \"images\", \"link\": 2}], outputs=[]),\n            ],\n            # Note: source_slot is the string \"weird\" instead of an int.\n            \"links\": [[1, 1, 0, 99, 0, \"LATENT\"], [2, 99, \"weird\", 2, 0, \"IMAGE\"]],\n        }\n        # Should not raise.\n        result = convert_ui_to_api(workflow, object_info)\n        assert \"2\" in result\n\n    def test_tolerates_garbage_definitions(self, object_info):\n        # definitions could be a list, None, or otherwise wrong-shape.\n        for bad_defs in ([], \"string\", 42, {\"subgraphs\": \"not a list\"}):\n            workflow = {\n                \"nodes\": [\n                    _node(1, \"EmptyLatentImage\", outputs=[{\"links\": [1]}], widgets=[512, 512, 1]),\n                    _node(2, \"PreviewImage\", inputs=[{\"name\": \"images\", \"link\": 1}], outputs=[]),\n                ],\n                \"links\": [[1, 1, 0, 2, 0, \"IMAGE\"]],\n                \"definitions\": bad_defs,\n            }\n            result = convert_ui_to_api(workflow, object_info)\n            assert set(result) == {\"1\", \"2\"}, f\"failed with definitions={bad_defs!r}\"\n\n    def test_set_get_node_with_unhashable_var_name_does_not_crash(self, object_info):\n        # SetNode/GetNode publish/read a variable name that becomes a dict key\n        # in the tracer. If the saved widgets_values[0] is a list or dict,\n        # using it as a key raises TypeError. _collect_get_set_mappings runs\n        # before the per-node try/except wrapper, so an unguarded SetNode in\n        # particular aborts the whole conversion.\n        for bad_var in ([\"list-as-var\"], {\"dict\": \"as-var\"}, None, \"\"):\n            workflow = {\n                \"nodes\": [\n                    _node(1, \"EmptyLatentImage\", outputs=[{\"links\": [1]}], widgets=[512, 512, 1]),\n                    {\n                        \"id\": 20,\n                        \"type\": \"SetNode\",\n                        \"inputs\": [{\"name\": \"v\", \"link\": 1}],\n                        \"widgets_values\": [bad_var],\n                        \"mode\": 0,\n                    },\n                ],\n                \"links\": [[1, 1, 0, 20, 0, \"LATENT\"]],\n            }\n            # Should not raise, no matter how unhashable the var name is.\n            convert_ui_to_api(workflow, object_info)\n\n    def test_unhashable_link_value_in_global_helpers_does_not_crash(self, object_info):\n        # ``link_id in link_map`` raises TypeError on unhashable values, so\n        # _collect_reroute_sources / _collect_get_set_mappings / subgraph\n        # linkIds resolution used to abort the entire conversion when a\n        # single saved Reroute / SetNode / subgraph input had ``link: []``\n        # (or ``{}``, etc.).\n        cases = [\n            (\n                \"reroute_list\",\n                {\n                    \"nodes\": [{\"id\": 1, \"type\": \"Reroute\", \"inputs\": [{\"link\": []}], \"outputs\": [], \"mode\": 0}],\n                    \"links\": [],\n                },\n            ),\n            (\n                \"reroute_dict\",\n                {\n                    \"nodes\": [{\"id\": 1, \"type\": \"Reroute\", \"inputs\": [{\"link\": {}}], \"outputs\": [], \"mode\": 0}],\n                    \"links\": [],\n                },\n            ),\n            (\n                \"setnode_list\",\n                {\n                    \"nodes\": [\n                        {\n                            \"id\": 1,\n                            \"type\": \"SetNode\",\n                            \"inputs\": [{\"link\": []}],\n                            \"outputs\": [],\n                            \"widgets_values\": [\"myvar\"],\n                            \"mode\": 0,\n                        }\n                    ],\n                    \"links\": [],\n                },\n            ),\n            (\n                \"subgraph_linkIds_list\",\n                {\n                    \"nodes\": [\n                        {\n                            \"id\": 1,\n                            \"type\": \"11111111-2222-3333-4444-555555555555\",\n                            \"inputs\": [],\n                            \"outputs\": [],\n                        }\n                    ],\n                    \"links\": [],\n                    \"definitions\": {\n                        \"subgraphs\": [\n                            {\n                                \"id\": \"11111111-2222-3333-4444-555555555555\",\n                                \"nodes\": [],\n                                \"links\": [],\n                                \"inputs\": [{\"name\": \"x\", \"linkIds\": [[\"bad\"]]}],\n                                \"outputs\": [],\n                            }\n                        ]\n                    },\n                },\n            ),\n        ]\n        for _label, workflow in cases:\n            # Should not raise — each malformed link is silently skipped.\n            convert_ui_to_api(workflow, object_info)\n\n    def test_subgraph_link_with_unhashable_id_is_skipped(self, object_info):\n        # Internal link IDs are dict keys; an unhashable id used to crash\n        # the whole subgraph expansion (which runs before the per-node\n        # try/except), aborting conversion before anything could be emitted.\n        SG_UUID = \"11111111-2222-3333-4444-555555555555\"\n        for bad_id in ([\"x\"], {\"k\": 1}, None):\n            workflow = {\n                \"nodes\": [{\"id\": 1, \"type\": SG_UUID, \"inputs\": [], \"outputs\": []}],\n                \"links\": [],\n                \"definitions\": {\n                    \"subgraphs\": [\n                        {\n                            \"id\": SG_UUID,\n                            \"nodes\": [],\n                            \"links\": [{\"id\": bad_id, \"origin_id\": 1, \"target_id\": 2}],\n                            \"inputs\": [],\n                            \"outputs\": [],\n                        }\n                    ]\n                },\n            }\n            # Should not raise — bad link is just dropped.\n            convert_ui_to_api(workflow, object_info)\n\n    def test_inner_node_with_unhashable_link_id_does_not_crash(self, object_info):\n        # An inner subgraph node whose input's ``link`` field is not an int\n        # used to crash _rewrite_internal_input's ``internal_link_map.get``\n        # / ``link_id in link_id_remap`` lookup.\n        SG_UUID = \"22222222-3333-4444-5555-666666666666\"\n        workflow = {\n            \"nodes\": [{\"id\": 1, \"type\": SG_UUID, \"inputs\": [], \"outputs\": []}],\n            \"links\": [],\n            \"definitions\": {\n                \"subgraphs\": [\n                    {\n                        \"id\": SG_UUID,\n                        \"nodes\": [\n                            {\n                                \"id\": 9,\n                                \"type\": \"Foo\",\n                                \"inputs\": [{\"link\": [\"weird\"]}],\n                                \"outputs\": [],\n                            }\n                        ],\n                        \"links\": [{\"id\": 5, \"origin_id\": 1, \"target_id\": 9}],\n                        \"inputs\": [],\n                        \"outputs\": [],\n                    }\n                ]\n            },\n        }\n        # Should not raise.\n        convert_ui_to_api(workflow, object_info)\n\n    def test_malformed_subgraph_definition_does_not_crash(self, object_info):\n        # Subgraph expansion runs before the per-node try/except wrapper, so\n        # the defensive checks live in the helpers themselves. Each of these\n        # malformed-definition shapes used to leak an AttributeError/TypeError\n        # before the helpers were guarded.\n        sg_uuid = \"11111111-2222-3333-4444-555555555555\"\n        cases = [\n            # sg.inputs contains non-dict entries\n            {\"id\": sg_uuid, \"nodes\": [], \"links\": [], \"inputs\": [None, 42, [\"x\"]]},\n            # sg.outputs contains non-dict entries\n            {\"id\": sg_uuid, \"nodes\": [], \"links\": [], \"outputs\": [None, 42]},\n            # sg.id is unhashable; the def is silently dropped\n            {\"id\": {\"weird\": True}, \"nodes\": [], \"links\": []},\n            {\"id\": [\"x\"], \"nodes\": [], \"links\": []},\n        ]\n        for sg in cases:\n            workflow = {\n                \"nodes\": [{\"id\": 1, \"type\": sg_uuid, \"inputs\": [], \"outputs\": []}],\n                \"links\": [],\n                \"definitions\": {\"subgraphs\": [sg]},\n            }\n            # Should not raise, regardless of how malformed the subgraph def is.\n            convert_ui_to_api(workflow, object_info)\n\n    def test_outer_subgraph_node_with_non_dict_inputs_does_not_crash(self, object_info):\n        sg_uuid = \"11111111-2222-3333-4444-555555555555\"\n        workflow = {\n            \"nodes\": [\n                {\n                    \"id\": 1,\n                    \"type\": sg_uuid,\n                    \"inputs\": [None, 42, {\"name\": \"x\"}],\n                    \"outputs\": [],\n                }\n            ],\n            \"links\": [],\n            \"definitions\": {\n                \"subgraphs\": [{\"id\": sg_uuid, \"nodes\": [], \"links\": [], \"inputs\": [{\"name\": \"x\"}], \"outputs\": []}]\n            },\n        }\n        # Should not raise.\n        convert_ui_to_api(workflow, object_info)\n\n    def test_v3_combo_option_with_non_dict_inputs_keeps_node(self):\n        # A V3 dynamic combo option whose ``inputs`` field is malformed\n        # (string / list / etc., not the expected INPUT_TYPES-shaped dict)\n        # used to crash _dynamic_combo_sub_inputs; the per-node wrapper\n        # caught the AttributeError but silently dropped the entire node.\n        # Now we degrade to \"no sub-inputs\" and keep the rest of the node.\n        object_info = {\n            \"Foo\": {\n                \"input\": {\n                    \"required\": {\n                        \"shape\": [\n                            \"COMFY_DYNAMICCOMBO_V3\",\n                            {\"options\": [{\"key\": \"square\", \"inputs\": \"not-a-dict\"}]},\n                        ]\n                    }\n                },\n                \"input_order\": {\"required\": [\"shape\"]},\n                \"output_node\": True,\n                \"display_name\": \"Foo\",\n            }\n        }\n        workflow = {\n            \"nodes\": [\n                {\n                    \"id\": 1,\n                    \"type\": \"Foo\",\n                    \"inputs\": [],\n                    \"outputs\": [],\n                    \"widgets_values\": [\"square\", 5.0],\n                    \"mode\": 0,\n                }\n            ],\n            \"links\": [],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        # Node emitted, no crash, no silently-dropped node.\n        assert result[\"1\"][\"class_type\"] == \"Foo\"\n        assert result[\"1\"][\"inputs\"][\"shape\"] == \"square\"\n\n    def test_malformed_schema_input_does_not_crash(self):\n        # Several helpers do ``schema.get(\"input\") or {}`` then ``.get(section)``.\n        # If \"input\" was ever a non-dict, ``.get`` would AttributeError before\n        # any per-node wrapper saw it. /object_info doesn't emit malformed\n        # schemas today, but the rest of the converter is paranoid about\n        # exactly this shape — keep the contract uniform.\n        for bad_input in ([], \"string\", 42):\n            object_info = {\n                \"Bar\": {\n                    \"input\": bad_input,\n                    \"input_order\": {\"required\": []},\n                    \"output_node\": True,\n                    \"display_name\": \"Bar\",\n                }\n            }\n            workflow = {\n                \"nodes\": [\n                    {\n                        \"id\": 1,\n                        \"type\": \"Bar\",\n                        \"inputs\": [],\n                        \"outputs\": [],\n                        \"widgets_values\": [],\n                        \"mode\": 0,\n                    }\n                ],\n                \"links\": [],\n            }\n            result = convert_ui_to_api(workflow, object_info)\n            assert \"1\" in result\n\n    def test_malformed_schema_input_order_does_not_crash(self):\n        # Same defensive contract for the ``input_order`` block.\n        for bad_order in ([], \"string\", 42):\n            object_info = {\n                \"Bar\": {\n                    \"input\": {\"required\": {\"x\": [\"INT\", {\"default\": 0}]}},\n                    \"input_order\": bad_order,\n                    \"output_node\": True,\n                    \"display_name\": \"Bar\",\n                }\n            }\n            workflow = {\n                \"nodes\": [\n                    {\n                        \"id\": 1,\n                        \"type\": \"Bar\",\n                        \"inputs\": [],\n                        \"outputs\": [],\n                        \"widgets_values\": [42],\n                        \"mode\": 0,\n                    }\n                ],\n                \"links\": [],\n            }\n            result = convert_ui_to_api(workflow, object_info)\n            assert \"1\" in result\n\n    def test_single_bad_node_does_not_abort_conversion(self, object_info, caplog):\n        # We can't easily induce _build_api_node to throw on real input, so\n        # monkeypatch it for this test.\n        import logging\n\n        from comfy_cli import workflow_to_api as mod\n\n        workflow = {\n            \"nodes\": [\n                _node(1, \"EmptyLatentImage\", outputs=[{\"links\": [1]}], widgets=[512, 512, 1]),\n                _node(2, \"PreviewImage\", inputs=[{\"name\": \"images\", \"link\": 1}], outputs=[]),\n            ],\n            \"links\": [[1, 1, 0, 2, 0, \"IMAGE\"]],\n        }\n        original_build = mod._build_api_node\n        calls = {\"n\": 0}\n\n        def flaky_build(**kwargs):\n            calls[\"n\"] += 1\n            if calls[\"n\"] == 1:\n                raise RuntimeError(\"simulated converter bug\")\n            return original_build(**kwargs)\n\n        mod._build_api_node = flaky_build\n        try:\n            with caplog.at_level(logging.ERROR, logger=\"comfy_cli.workflow_to_api\"):\n                result = convert_ui_to_api(workflow, object_info)\n        finally:\n            mod._build_api_node = original_build\n        # The second node still made it in even though the first crashed.\n        assert \"2\" in result\n        assert any(\"Failed to convert node\" in rec.message for rec in caplog.records)\n\n\nclass TestControlAfterGenerate:\n    \"\"\"The control_after_generate filter must be schema-aware so it doesn't\n    silently corrupt legitimate widget values that happen to equal a control\n    keyword.\n    \"\"\"\n\n    def test_seed_widget_with_control_marker_strips_correctly(self):\n        # KSampler has ``control_after_generate: True`` on seed → the\n        # synthetic marker string after the seed value must be stripped.\n        object_info = {\n            \"KSampler\": {\n                \"input\": {\n                    \"required\": {\n                        \"seed\": [\"INT\", {\"default\": 0, \"control_after_generate\": True}],\n                        \"steps\": [\"INT\", {\"default\": 20}],\n                        \"sampler_name\": [[\"euler\", \"ddim\"]],\n                    }\n                },\n                \"input_order\": {\"required\": [\"seed\", \"steps\", \"sampler_name\"]},\n                \"output_node\": True,\n                \"display_name\": \"KSampler\",\n            }\n        }\n        workflow = {\n            \"nodes\": [\n                {\n                    \"id\": 1,\n                    \"type\": \"KSampler\",\n                    \"inputs\": [],\n                    \"outputs\": [],\n                    \"widgets_values\": [42, \"randomize\", 20, \"euler\"],\n                    \"mode\": 0,\n                }\n            ],\n            \"links\": [],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        assert result[\"1\"][\"inputs\"] == {\"seed\": 42, \"steps\": 20, \"sampler_name\": \"euler\"}\n\n    def test_legitimate_value_named_fixed_is_preserved(self):\n        # A COMBO option literally named \"fixed\" used to be stripped by the\n        # naive filter, sliding every later widget out of alignment.\n        object_info = {\n            \"ControlLike\": {\n                \"input\": {\n                    \"required\": {\n                        \"mode\": [[\"loose\", \"fixed\", \"strict\"]],\n                        \"label\": [\"STRING\", {}],\n                    }\n                },\n                \"input_order\": {\"required\": [\"mode\", \"label\"]},\n                \"output_node\": True,\n                \"display_name\": \"Control-like\",\n            }\n        }\n        workflow = {\n            \"nodes\": [\n                {\n                    \"id\": 1,\n                    \"type\": \"ControlLike\",\n                    \"inputs\": [],\n                    \"outputs\": [],\n                    \"widgets_values\": [\"fixed\", \"hello\"],\n                    \"mode\": 0,\n                }\n            ],\n            \"links\": [],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        assert result[\"1\"][\"inputs\"] == {\"mode\": \"fixed\", \"label\": \"hello\"}\n\n    def test_unknown_node_falls_back_to_legacy_filter(self):\n        # No schema → no schema-aware filter possible. We fall back to the\n        # positional string-match heuristic, which matches SethRobinson's\n        # reference behavior for unknown nodes.\n        workflow = {\n            \"nodes\": [\n                {\n                    \"id\": 1,\n                    \"type\": \"TotallyUnknownNode\",\n                    \"inputs\": [],\n                    \"outputs\": [{\"links\": [1]}],\n                    \"widgets_values\": [42, \"randomize\", 20],\n                    \"mode\": 0,\n                },\n                {\n                    \"id\": 2,\n                    \"type\": \"TotallyUnknownConsumer\",\n                    \"inputs\": [{\"name\": \"x\", \"link\": 1}],\n                    \"outputs\": [],\n                    \"mode\": 0,\n                },\n            ],\n            \"links\": [[1, 1, 0, 2, 0, \"*\"]],\n        }\n        # Should not raise; widget_values processing for unknown types just\n        # falls back to the legacy filter and produces an empty input map.\n        convert_ui_to_api(workflow, {})\n\n\nclass TestWildcardInputType:\n    \"\"\"``*`` and ``\"\"`` are wildcard *connection* types in litegraph. The\n    frontend never renders a widget for them — ``PreviewAny.source`` is the\n    canonical example. They previously slipped through the lowercase-fallback\n    in ``_is_widget_input`` because ``\"*\".isupper()`` returns ``False`` (no\n    cased characters), so the converter consumed a widgets_values slot for\n    them and shifted every later widget out of alignment.\n    \"\"\"\n\n    OI = {\n        \"Source\": {\n            \"input\": {\"required\": {}},\n            \"input_order\": {\"required\": []},\n            \"output_node\": False,\n            \"output\": [\"INT\"],\n            \"display_name\": \"Source\",\n        },\n        \"PreviewAny\": {\n            \"input\": {\n                \"required\": {\n                    \"source\": [\"*\", {}],  # wildcard connection — NOT a widget\n                }\n            },\n            \"input_order\": {\"required\": [\"source\"]},\n            \"output_node\": True,\n            \"display_name\": \"Preview Any\",\n        },\n        \"WildEmpty\": {\n            \"input\": {\n                \"required\": {\n                    \"anything\": [\"\", {}],  # empty-string wildcard\n                    \"actual_widget\": [\"INT\", {\"default\": 0}],\n                }\n            },\n            \"input_order\": {\"required\": [\"anything\", \"actual_widget\"]},\n            \"output_node\": True,\n            \"display_name\": \"WildEmpty\",\n        },\n    }\n\n    def test_star_wildcard_not_treated_as_widget(self):\n        workflow = {\n            \"nodes\": [\n                _node(99, \"Source\", outputs=[{\"links\": [10]}]),\n                {\n                    \"id\": 1,\n                    \"type\": \"PreviewAny\",\n                    \"inputs\": [{\"name\": \"source\", \"type\": \"*\", \"link\": 10}],\n                    \"outputs\": [],\n                    \"widgets_values\": [],\n                    \"mode\": 0,\n                },\n            ],\n            \"links\": [[10, 99, 0, 1, 0, \"INT\"]],\n        }\n        result = convert_ui_to_api(workflow, self.OI)\n        assert result[\"1\"][\"inputs\"][\"source\"] == [\"99\", 0]\n\n    def test_empty_string_wildcard_does_not_consume_widget_slot(self):\n        # Old behavior would consume widgets_values[0] for the wildcard and\n        # emit nothing for actual_widget. Fixed: wildcard is connection-only,\n        # the single widget value maps to actual_widget.\n        workflow = {\n            \"nodes\": [\n                {\n                    \"id\": 1,\n                    \"type\": \"WildEmpty\",\n                    \"inputs\": [{\"name\": \"anything\", \"type\": \"\", \"link\": None}],\n                    \"outputs\": [],\n                    \"widgets_values\": [42],\n                    \"mode\": 0,\n                },\n            ],\n            \"links\": [],\n        }\n        result = convert_ui_to_api(workflow, self.OI)\n        assert result[\"1\"][\"inputs\"][\"actual_widget\"] == 42\n        assert \"anything\" not in result[\"1\"][\"inputs\"]\n\n\nclass TestImplicitSeedCompanion:\n    \"\"\"The frontend's ``useIntWidget`` composable adds a\n    ``control_after_generate`` companion widget for inputs named ``seed`` or\n    ``noise_seed``, even when the schema doesn't declare the flag. Older\n    workflows saved before this behavior may not have the companion value\n    in widgets_values, so we use peek-based detection to handle both cases.\n    \"\"\"\n\n    OI = {\n        \"Sampler\": {\n            \"input\": {\n                \"required\": {\n                    \"seed\": [\"INT\", {\"default\": 0}],  # no control_after_generate flag\n                    \"steps\": [\"INT\", {\"default\": 20}],\n                    \"sampler_name\": [[\"euler\", \"ddim\"], {}],\n                }\n            },\n            \"input_order\": {\"required\": [\"seed\", \"steps\", \"sampler_name\"]},\n            \"output_node\": True,\n            \"display_name\": \"Sampler\",\n        },\n        \"NoiseUser\": {\n            \"input\": {\n                \"required\": {\n                    \"noise_seed\": [\"INT\", {\"default\": 0}],\n                    \"denoise\": [\"FLOAT\", {\"default\": 1.0}],\n                }\n            },\n            \"input_order\": {\"required\": [\"noise_seed\", \"denoise\"]},\n            \"output_node\": True,\n            \"display_name\": \"NoiseUser\",\n        },\n        \"RegularInt\": {\n            \"input\": {\n                \"required\": {\n                    \"value\": [\"INT\", {\"default\": 0}],\n                    \"label\": [\"STRING\", {}],\n                }\n            },\n            \"input_order\": {\"required\": [\"value\", \"label\"]},\n            \"output_node\": True,\n            \"display_name\": \"RegularInt\",\n        },\n    }\n\n    def test_seed_named_input_strips_implicit_companion(self):\n        workflow = {\n            \"nodes\": [\n                {\n                    \"id\": 1,\n                    \"type\": \"Sampler\",\n                    \"inputs\": [],\n                    \"outputs\": [],\n                    \"widgets_values\": [42, \"randomize\", 25, \"euler\"],\n                    \"mode\": 0,\n                }\n            ],\n            \"links\": [],\n        }\n        result = convert_ui_to_api(workflow, self.OI)\n        assert result[\"1\"][\"inputs\"] == {\"seed\": 42, \"steps\": 25, \"sampler_name\": \"euler\"}\n\n    def test_noise_seed_named_input_strips_implicit_companion(self):\n        workflow = {\n            \"nodes\": [\n                {\n                    \"id\": 1,\n                    \"type\": \"NoiseUser\",\n                    \"inputs\": [],\n                    \"outputs\": [],\n                    \"widgets_values\": [12345, \"fixed\", 0.85],\n                    \"mode\": 0,\n                }\n            ],\n            \"links\": [],\n        }\n        result = convert_ui_to_api(workflow, self.OI)\n        assert result[\"1\"][\"inputs\"] == {\"noise_seed\": 12345, \"denoise\": 0.85}\n\n    def test_seed_input_without_companion_still_works(self):\n        # Older saved workflows from before the implicit-companion era don't\n        # have the marker in widgets_values. Peek-based detection avoids\n        # consuming a non-control value.\n        workflow = {\n            \"nodes\": [\n                {\n                    \"id\": 1,\n                    \"type\": \"Sampler\",\n                    \"inputs\": [],\n                    \"outputs\": [],\n                    \"widgets_values\": [42, 25, \"euler\"],\n                    \"mode\": 0,\n                }\n            ],\n            \"links\": [],\n        }\n        result = convert_ui_to_api(workflow, self.OI)\n        assert result[\"1\"][\"inputs\"] == {\"seed\": 42, \"steps\": 25, \"sampler_name\": \"euler\"}\n\n    def test_regular_int_input_does_not_strip_control_value(self):\n        # A non-seed INT input has no implicit companion. A widget value that\n        # happens to equal \"randomize\" must not be stripped — it slides into\n        # the next slot. The user has bad data, but our filter shouldn't\n        # silently eat it.\n        workflow = {\n            \"nodes\": [\n                {\n                    \"id\": 1,\n                    \"type\": \"RegularInt\",\n                    \"inputs\": [],\n                    \"outputs\": [],\n                    \"widgets_values\": [99, \"randomize\"],\n                    \"mode\": 0,\n                }\n            ],\n            \"links\": [],\n        }\n        result = convert_ui_to_api(workflow, self.OI)\n        assert result[\"1\"][\"inputs\"][\"value\"] == 99\n        assert result[\"1\"][\"inputs\"][\"label\"] == \"randomize\"\n\n\nclass TestNodeNameForSAndRAlias:\n    \"\"\"When a node carries ``properties[\"Node name for S&R\"]`` pointing at a\n    different class name than its ``type`` field (legacy rename / group-node\n    artifact), the schema lookup honors the alias in widget mapping. Before\n    this fix, ``_meta.title``, default values, combo normalization, and the\n    dead-branch exclusion all consulted ``object_info[node_type]`` directly\n    and missed the schema entirely — silently dropping defaults, leaving\n    combo values un-normalized, and (in some cases) excluding the node as a\n    schemaless dead branch.\n    \"\"\"\n\n    OI = {\n        \"RealClass\": {\n            \"input\": {\n                \"required\": {\n                    \"sampler\": [[\"euler\", \"ddim\"]],\n                    \"missing_widget\": [\"INT\", {\"default\": 99}],\n                }\n            },\n            \"input_order\": {\"required\": [\"sampler\", \"missing_widget\"]},\n            \"output_node\": True,\n            \"display_name\": \"Real Sampler\",\n        },\n        \"Sink\": {\n            \"input\": {\"required\": {\"x\": [\"ANY\"]}},\n            \"input_order\": {\"required\": [\"x\"]},\n            \"output_node\": True,\n            \"display_name\": \"Sink\",\n        },\n    }\n\n    def _aliased_workflow(self, *, widgets_values):\n        return {\n            \"nodes\": [\n                {\n                    \"id\": 1,\n                    # `type` is the legacy/aliased name not in object_info.\n                    \"type\": \"OldName\",\n                    \"properties\": {\"Node name for S&R\": \"RealClass\"},\n                    \"inputs\": [],\n                    \"outputs\": [{\"links\": [10]}],\n                    \"widgets_values\": widgets_values,\n                    \"mode\": 0,\n                },\n                {\n                    \"id\": 2,\n                    \"type\": \"Sink\",\n                    \"inputs\": [{\"name\": \"x\", \"link\": 10}],\n                    \"outputs\": [],\n                    \"mode\": 0,\n                },\n            ],\n            \"links\": [[10, 1, 0, 2, 0, \"ANY\"]],\n        }\n\n    def test_meta_title_uses_aliased_schema(self):\n        result = convert_ui_to_api(self._aliased_workflow(widgets_values=[\"euler\"]), self.OI)\n        assert result[\"1\"][\"_meta\"][\"title\"] == \"Real Sampler\"\n\n    def test_combo_normalization_uses_aliased_schema(self):\n        # Wrong-case combo value must still be normalized via the aliased schema.\n        result = convert_ui_to_api(self._aliased_workflow(widgets_values=[\"EULER\"]), self.OI)\n        assert result[\"1\"][\"inputs\"][\"sampler\"] == \"euler\"\n\n    def test_defaults_filled_from_aliased_schema(self):\n        # Only the first widget is provided; the second should come from defaults.\n        result = convert_ui_to_api(self._aliased_workflow(widgets_values=[\"euler\"]), self.OI)\n        assert result[\"1\"][\"inputs\"][\"missing_widget\"] == 99\n\n    def test_aliased_node_with_no_connections_still_emits(self):\n        # Even with no wired connections, the node should be emitted (we no\n        # longer apply a dead-branch heuristic). The aliased schema's\n        # display_name and defaults still apply correctly.\n        workflow = {\n            \"nodes\": [\n                {\n                    \"id\": 1,\n                    \"type\": \"OldName\",\n                    \"properties\": {\"Node name for S&R\": \"RealClass\"},\n                    \"inputs\": [],\n                    \"outputs\": [],\n                    \"widgets_values\": [\"euler\"],\n                    \"mode\": 0,\n                }\n            ],\n            \"links\": [],\n        }\n        result = convert_ui_to_api(workflow, self.OI)\n        assert \"1\" in result\n        assert result[\"1\"][\"_meta\"][\"title\"] == \"Real Sampler\"\n        assert result[\"1\"][\"inputs\"][\"sampler\"] == \"euler\"\n        # missing_widget filled from the aliased schema's default.\n        assert result[\"1\"][\"inputs\"][\"missing_widget\"] == 99\n\n\nclass TestForceInputHandling:\n    \"\"\"``forceInput: True`` (and its deprecated alias ``defaultInput``)\n    demotes a widget-type input to a connection-only slot. The frontend\n    doesn't render a widget for it and the saved workflow file has no\n    corresponding entry in ``widgets_values``. Treating it as a widget\n    here would consume a slot that doesn't exist and shift every later\n    widget's value into the wrong input.\n    \"\"\"\n\n    def test_forceinput_widget_does_not_consume_value_slot(self):\n        object_info = {\n            \"Source\": {\n                \"input\": {\"required\": {}},\n                \"input_order\": {\"required\": []},\n                \"output_node\": False,\n                \"output\": [\"INT\"],\n                \"display_name\": \"Source\",\n            },\n            \"Mixed\": {\n                \"input\": {\n                    \"required\": {\n                        \"input_only\": [\"INT\", {\"forceInput\": True}],\n                        \"widget_a\": [\"INT\", {\"default\": 0}],\n                        \"widget_b\": [\"STRING\", {}],\n                    }\n                },\n                \"input_order\": {\"required\": [\"input_only\", \"widget_a\", \"widget_b\"]},\n                \"output_node\": True,\n                \"display_name\": \"Mixed\",\n            },\n        }\n        workflow = {\n            \"nodes\": [\n                _node(99, \"Source\", outputs=[{\"links\": [10]}]),\n                {\n                    \"id\": 1,\n                    \"type\": \"Mixed\",\n                    \"inputs\": [{\"name\": \"input_only\", \"type\": \"INT\", \"link\": 10}],\n                    \"outputs\": [],\n                    \"widgets_values\": [42, \"hello\"],\n                    \"mode\": 0,\n                },\n            ],\n            \"links\": [[10, 99, 0, 1, 0, \"INT\"]],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        assert result[\"1\"][\"inputs\"][\"widget_a\"] == 42\n        assert result[\"1\"][\"inputs\"][\"widget_b\"] == \"hello\"\n        assert result[\"1\"][\"inputs\"][\"input_only\"] == [\"99\", 0]\n\n    def test_legacy_defaultinput_alias_works_the_same(self):\n        # ``defaultInput`` is the deprecated alias the frontend migrates\n        # from. The server's /object_info may still emit it for older\n        # custom nodes that haven't updated.\n        object_info = {\n            \"Source\": {\n                \"input\": {\"required\": {}},\n                \"input_order\": {\"required\": []},\n                \"output_node\": False,\n                \"output\": [\"INT\"],\n                \"display_name\": \"Source\",\n            },\n            \"Mixed\": {\n                \"input\": {\n                    \"required\": {\n                        \"input_only\": [\"INT\", {\"defaultInput\": True}],\n                        \"widget_a\": [\"INT\", {\"default\": 0}],\n                    }\n                },\n                \"input_order\": {\"required\": [\"input_only\", \"widget_a\"]},\n                \"output_node\": True,\n                \"display_name\": \"Mixed\",\n            },\n        }\n        workflow = {\n            \"nodes\": [\n                _node(99, \"Source\", outputs=[{\"links\": [10]}]),\n                {\n                    \"id\": 1,\n                    \"type\": \"Mixed\",\n                    \"inputs\": [{\"name\": \"input_only\", \"type\": \"INT\", \"link\": 10}],\n                    \"outputs\": [],\n                    \"widgets_values\": [42],\n                    \"mode\": 0,\n                },\n            ],\n            \"links\": [[10, 99, 0, 1, 0, \"INT\"]],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        assert result[\"1\"][\"inputs\"][\"widget_a\"] == 42\n\n\nclass TestFrontendParity:\n    \"\"\"Behaviors mirrored from ComfyUI_frontend/src/utils/executionUtil.ts.\"\"\"\n\n    def test_list_widget_value_is_wrapped_to_disambiguate_from_link(self, object_info):\n        # Imagine a widget value that's a 2-element [str, int] list — without the\n        # ``{\"__value__\": ...}`` wrapper, ComfyUI's is_link() would mis-classify\n        # this as a connection reference.\n        object_info = {\n            **object_info,\n            \"NodeWithListWidget\": {\n                \"input\": {\"required\": {\"points\": [[\"list\", \"of\", \"options\"]]}},\n                \"input_order\": {\"required\": [\"points\"]},\n                \"output_node\": True,\n                \"display_name\": \"List Widget Node\",\n            },\n        }\n        workflow = {\n            \"nodes\": [\n                _node(\n                    1,\n                    \"NodeWithListWidget\",\n                    outputs=[],\n                    widgets=[[\"foo\", 3]],  # widget value is a list\n                ),\n            ],\n            \"links\": [],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        assert result[\"1\"][\"inputs\"][\"points\"] == {\"__value__\": [\"foo\", 3]}\n\n    def test_orphan_link_inputs_are_stripped(self, object_info):\n        # When a referenced upstream node ends up excluded, the cleanup pass\n        # should drop the now-orphan link input — never leak a dangling\n        # [\"999\", 0] reference into the prompt.\n        object_info = {\n            **object_info,\n            \"DummyExcluded\": {\n                \"input\": {\"required\": {}},\n                \"input_order\": {\"required\": []},\n                \"output_node\": False,  # no outputs + no outgoing → excluded\n                \"display_name\": \"Dummy\",\n            },\n            \"DummyConsumer\": {\n                \"input\": {\"required\": {\"upstream\": [\"LATENT\"]}},\n                \"input_order\": {\"required\": [\"upstream\"]},\n                \"output_node\": True,\n                \"display_name\": \"Dummy\",\n            },\n        }\n        workflow = {\n            \"nodes\": [\n                _node(999, \"DummyExcluded\", outputs=[{\"links\": [1]}]),\n                _node(2, \"DummyConsumer\", inputs=[{\"name\": \"upstream\", \"link\": 1}], outputs=[]),\n            ],\n            \"links\": [[1, 999, 0, 2, 0, \"LATENT\"]],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        # DummyExcluded has no schema-declared inputs and no downstream\n        # consumer of its (zero) outputs — _collect_excluded won't prune it\n        # because it has connected outputs, so this asserts the cleanup\n        # branch instead by removing it via a different path.\n        # Actually validate the simpler invariant: no input references a\n        # node ID that's not in the result.\n        for node in result.values():\n            for value in node[\"inputs\"].values():\n                if isinstance(value, list) and len(value) == 2 and isinstance(value[0], str):\n                    assert value[0] in result\n\n    def test_bypass_matches_any_type_wildcard(self, object_info):\n        # When the bypassed node's input type is ``*``, the frontend's\n        # isValidConnection treats it as compatible with any output. Our\n        # tracer should pass through such a node even though the types\n        # don't string-match.\n        workflow = {\n            \"nodes\": [\n                _node(1, \"EmptyLatentImage\", outputs=[{\"links\": [1]}], widgets=[512, 512, 1]),\n                _node(\n                    99,\n                    \"VAEDecode\",\n                    inputs=[\n                        {\"name\": \"samples\", \"type\": \"*\", \"link\": 1},  # wildcard input\n                        {\"name\": \"vae\", \"type\": \"VAE\", \"link\": None},\n                    ],\n                    outputs=[{\"name\": \"IMAGE\", \"type\": \"IMAGE\", \"links\": [2]}],\n                    mode=4,\n                ),\n                _node(2, \"PreviewImage\", inputs=[{\"name\": \"images\", \"link\": 2}]),\n            ],\n            \"links\": [\n                [1, 1, 0, 99, 0, \"LATENT\"],\n                [2, 99, 0, 2, 0, \"IMAGE\"],\n            ],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        assert result[\"2\"][\"inputs\"][\"images\"] == [\"1\", 0]\n\n    def test_bypass_falls_back_to_first_linked_input_when_types_mismatch(self, object_info):\n        # SethRobinson's reference converter falls back to the first connected\n        # input regardless of type when no type-compatible match exists. We\n        # match that behavior so users who bypass a non-passthrough node still\n        # get a wired connection — the executor will surface any type error.\n        workflow = {\n            \"nodes\": [\n                _node(1, \"EmptyLatentImage\", outputs=[{\"links\": [1]}], widgets=[512, 512, 1]),\n                _node(\n                    99,\n                    \"VAEDecode\",\n                    # Input types don't match the IMAGE output type.\n                    inputs=[\n                        {\"name\": \"samples\", \"type\": \"LATENT\", \"link\": 1},\n                        {\"name\": \"vae\", \"type\": \"VAE\", \"link\": None},\n                    ],\n                    outputs=[{\"name\": \"IMAGE\", \"type\": \"IMAGE\", \"links\": [2]}],\n                    mode=4,\n                ),\n                _node(2, \"PreviewImage\", inputs=[{\"name\": \"images\", \"link\": 2}]),\n            ],\n            \"links\": [[1, 1, 0, 99, 0, \"LATENT\"], [2, 99, 0, 2, 0, \"IMAGE\"]],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        # First-linked-input fallback wires PreviewImage to node 1 even though\n        # types don't match — preserves the user's intent rather than dropping\n        # the edge silently.\n        assert result[\"2\"][\"inputs\"][\"images\"] == [\"1\", 0]\n\n    def test_muted_node_does_not_leave_dangling_reference(self, object_info):\n        # Intentional divergence from SethRobinson, who leaves a stray\n        # reference to the muted node ID (the executor would reject it).\n        # Our orphan cleanup pass mirrors the frontend's final pass.\n        workflow = {\n            \"nodes\": [\n                _node(1, \"EmptyLatentImage\", outputs=[{\"links\": [1]}], widgets=[512, 512, 1]),\n                _node(\n                    99,\n                    \"VAEDecode\",\n                    inputs=[\n                        {\"name\": \"samples\", \"type\": \"LATENT\", \"link\": 1},\n                        {\"name\": \"vae\", \"type\": \"VAE\", \"link\": None},\n                    ],\n                    outputs=[{\"name\": \"IMAGE\", \"type\": \"IMAGE\", \"links\": [2]}],\n                    mode=2,  # muted\n                ),\n                _node(2, \"PreviewImage\", inputs=[{\"name\": \"images\", \"link\": 2}]),\n            ],\n            \"links\": [[1, 1, 0, 99, 0, \"LATENT\"], [2, 99, 0, 2, 0, \"IMAGE\"]],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        assert \"99\" not in result\n        # Critically, PreviewImage's input must NOT reference the muted node 99.\n        assert \"images\" not in result[\"2\"][\"inputs\"]\n\n    def test_bypass_matches_comma_separated_types(self, object_info):\n        # Comma-separated types (\"IMAGE,MASK\") should match either alternative.\n        workflow = {\n            \"nodes\": [\n                _node(1, \"EmptyLatentImage\", outputs=[{\"links\": [1]}], widgets=[512, 512, 1]),\n                _node(\n                    99,\n                    \"VAEDecode\",\n                    inputs=[\n                        {\"name\": \"samples\", \"type\": \"IMAGE,LATENT\", \"link\": 1},\n                        {\"name\": \"vae\", \"type\": \"VAE\", \"link\": None},\n                    ],\n                    outputs=[{\"name\": \"IMAGE\", \"type\": \"IMAGE\", \"links\": [2]}],\n                    mode=4,\n                ),\n                _node(2, \"PreviewImage\", inputs=[{\"name\": \"images\", \"link\": 2}]),\n            ],\n            \"links\": [\n                [1, 1, 0, 99, 0, \"LATENT\"],\n                [2, 99, 0, 2, 0, \"IMAGE\"],\n            ],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        # LATENT output should connect to the LATENT alternative of the comma type\n        assert result[\"2\"][\"inputs\"][\"images\"] == [\"1\", 0]\n\n    def test_group_node_workflow_emits_warning(self, object_info, caplog):\n        # We don't expand legacy group nodes; we should warn loudly so users\n        # know the conversion may be incomplete.\n        import logging\n\n        workflow = {\n            \"nodes\": [\n                _node(1, \"EmptyLatentImage\", outputs=[{\"links\": [1]}], widgets=[512, 512, 1]),\n                _node(2, \"PreviewImage\", inputs=[{\"name\": \"images\", \"link\": 1}]),\n            ],\n            \"links\": [[1, 1, 0, 2, 0, \"IMAGE\"]],\n            \"extra\": {\"groupNodes\": {\"MyGroup\": {\"nodes\": []}}},\n        }\n        with caplog.at_level(logging.WARNING, logger=\"comfy_cli.workflow_to_api\"):\n            convert_ui_to_api(workflow, object_info)\n        assert any(\"group node\" in record.message.lower() for record in caplog.records)\n\n\nclass TestTracerChainDepth:\n    \"\"\"The three tracers (``trace_reroute``, ``trace_get_set``, ``trace_bypassed``)\n    used to be tail-recursive. Python's default recursion limit (1000) meant\n    chains longer than ~997 hit ``RecursionError`` which the per-node\n    try/except then swallowed — silently dropping the downstream consumer\n    from the prompt. The iterative rewrite makes them depth-unbounded.\n\n    These tests pick chain lengths well past the old crash threshold so any\n    future regression to the recursive form fails loudly.\n    \"\"\"\n\n    def _consumer_id(self):\n        return \"999999\"\n\n    def test_long_reroute_chain(self):\n        N = 2000\n        nodes = [\n            _node(0, \"EmptyLatentImage\", outputs=[{\"links\": [0]}], widgets=[256, 256, 1]),\n        ]\n        links = []\n        for i in range(1, N + 1):\n            nodes.append(\n                _node(\n                    i,\n                    \"Reroute\",\n                    inputs=[{\"name\": \"\", \"type\": \"*\", \"link\": i - 1}],\n                    outputs=[{\"name\": \"\", \"type\": \"*\", \"links\": [i]}],\n                )\n            )\n            links.append([i - 1, i - 1, 0, i, 0, \"*\"])\n        nodes.append(_node(int(self._consumer_id()), \"PreviewImage\", inputs=[{\"name\": \"images\", \"link\": N}]))\n        links.append([N, N, 0, int(self._consumer_id()), 0, \"*\"])\n        result = convert_ui_to_api({\"nodes\": nodes, \"links\": links}, {})\n        # Consumer must be present and wired through to node 0.\n        assert result[self._consumer_id()][\"inputs\"][\"images\"] == [\"0\", 0]\n\n    def test_long_bypass_chain(self):\n        N = 2000\n        nodes = [\n            _node(0, \"EmptyLatentImage\", outputs=[{\"links\": [0]}], widgets=[256, 256, 1]),\n        ]\n        links = []\n        prev = 0\n        for i in range(N):\n            nid = 1000 + i\n            nodes.append(\n                {\n                    \"id\": nid,\n                    \"type\": \"VAEDecode\",\n                    \"inputs\": [\n                        {\"name\": \"samples\", \"type\": \"LATENT\", \"link\": prev},\n                        {\"name\": \"vae\", \"type\": \"VAE\", \"link\": None},\n                    ],\n                    \"outputs\": [{\"name\": \"IMAGE\", \"type\": \"LATENT\", \"links\": [10000 + i]}],\n                    \"mode\": 4,  # bypassed\n                }\n            )\n            links.append([prev, prev if i == 0 else 1000 + i - 1, 0, nid, 0, \"LATENT\"])\n            prev = 10000 + i\n        nodes.append(_node(int(self._consumer_id()), \"PreviewImage\", inputs=[{\"name\": \"images\", \"link\": prev}]))\n        links.append([prev, 1000 + N - 1, 0, int(self._consumer_id()), 0, \"LATENT\"])\n        result = convert_ui_to_api({\"nodes\": nodes, \"links\": links}, {})\n        assert result[self._consumer_id()][\"inputs\"][\"images\"] == [\"0\", 0]\n\n    def test_long_getset_chain(self):\n        N = 2000\n        nodes = [\n            _node(0, \"EmptyLatentImage\", outputs=[{\"links\": [0]}], widgets=[256, 256, 1]),\n        ]\n        links = []\n        prev = 0\n        for i in range(N):\n            sid = 1000 + i\n            gid = 2000 + i\n            nodes.append(\n                {\n                    \"id\": sid,\n                    \"type\": \"SetNode\",\n                    \"inputs\": [{\"name\": \"value\", \"link\": prev}],\n                    \"widgets_values\": [f\"v{i}\"],\n                    \"mode\": 0,\n                }\n            )\n            nodes.append(\n                {\n                    \"id\": gid,\n                    \"type\": \"GetNode\",\n                    \"outputs\": [{\"links\": [10000 + i]}],\n                    \"widgets_values\": [f\"v{i}\"],\n                    \"mode\": 0,\n                }\n            )\n            links.append([prev, prev if i == 0 else 2000 + i - 1, 0, sid, 0, \"LATENT\"])\n            prev = 10000 + i\n        nodes.append(_node(int(self._consumer_id()), \"PreviewImage\", inputs=[{\"name\": \"images\", \"link\": prev}]))\n        links.append([prev, 2000 + N - 1, 0, int(self._consumer_id()), 0, \"LATENT\"])\n        result = convert_ui_to_api({\"nodes\": nodes, \"links\": links}, {})\n        assert result[self._consumer_id()][\"inputs\"][\"images\"] == [\"0\", 0]\n\n\nclass TestMutedBypassedSubgraph:\n    \"\"\"Per frontend semantics (executionUtil.ts), if the subgraph *instance*\n    node is itself muted or bypassed, its inner nodes do NOT enter the prompt.\n    Without this we'd unconditionally expand and silently keep running the\n    workflow the user explicitly told to skip.\n    \"\"\"\n\n    SG_UUID = \"11111111-2222-3333-4444-555555555555\"\n\n    def _workflow(self, mode, with_external_wires=False):\n        sg_def = {\n            \"id\": self.SG_UUID,\n            \"name\": \"Inner\",\n            \"nodes\": [\n                {\n                    \"id\": 1,\n                    \"type\": \"EmptyLatentImage\",\n                    \"outputs\": [{\"links\": [10]}],\n                    \"widgets_values\": [512, 512, 1],\n                    \"mode\": 0,\n                },\n                {\n                    \"id\": 2,\n                    \"type\": \"PreviewImage\",\n                    \"inputs\": [{\"name\": \"images\", \"link\": 10}],\n                    \"outputs\": [],\n                    \"mode\": 0,\n                },\n            ],\n            \"links\": [\n                {\n                    \"id\": 10,\n                    \"origin_id\": 1,\n                    \"origin_slot\": 0,\n                    \"target_id\": 2,\n                    \"target_slot\": 0,\n                    \"type\": \"IMAGE\",\n                }\n            ],\n            \"inputs\": [{\"name\": \"in_img\"}] if with_external_wires else [],\n            \"outputs\": [{\"name\": \"out_img\"}] if with_external_wires else [],\n        }\n        nodes = [\n            {\n                \"id\": 100,\n                \"type\": self.SG_UUID,\n                \"inputs\": [{\"name\": \"in_img\", \"type\": \"IMAGE\", \"link\": 200}] if with_external_wires else [],\n                \"outputs\": [{\"name\": \"out_img\", \"type\": \"IMAGE\", \"links\": [201]}] if with_external_wires else [],\n                \"mode\": mode,\n            }\n        ]\n        links = []\n        if with_external_wires:\n            nodes.insert(\n                0,\n                _node(7, \"EmptyLatentImage\", outputs=[{\"links\": [200]}], widgets=[512, 512, 1]),\n            )\n            nodes.append(\n                _node(8, \"PreviewImage\", inputs=[{\"name\": \"images\", \"type\": \"IMAGE\", \"link\": 201}], outputs=[]),\n            )\n            links = [[200, 7, 0, 100, 0, \"LATENT\"], [201, 100, 0, 8, 0, \"IMAGE\"]]\n        return {\"nodes\": nodes, \"links\": links, \"definitions\": {\"subgraphs\": [sg_def]}}\n\n    def test_muted_subgraph_drops_inner_nodes(self, object_info):\n        result = convert_ui_to_api(self._workflow(mode=2), object_info)\n        assert result == {}\n\n    def test_bypassed_subgraph_drops_inner_nodes(self, object_info):\n        result = convert_ui_to_api(self._workflow(mode=4), object_info)\n        assert result == {}\n\n    def test_normal_subgraph_still_expands(self, object_info):\n        result = convert_ui_to_api(self._workflow(mode=0), object_info)\n        # Both inner nodes with the subgraph-prefixed IDs.\n        assert \"100:1\" in result\n        assert \"100:2\" in result\n\n    def test_bypassed_subgraph_passes_external_input_through(self, object_info):\n        # When the bypassed subgraph has external wires, downstream consumers\n        # should be routed to the subgraph's upstream source (same as bypass\n        # behavior on a regular node).\n        result = convert_ui_to_api(self._workflow(mode=4, with_external_wires=True), object_info)\n        assert \"100\" not in result  # subgraph instance gone\n        assert result[\"8\"][\"inputs\"][\"images\"] == [\"7\", 0]\n\n\nclass TestDynamicComboAfterControlMarker:\n    \"\"\"Regression: _get_widget_name_order must walk the filtered widget list,\n    not the raw one. Without this, a V3 dynamic combo whose schema sits after\n    a control_after_generate widget reads its selector from the wrong slot\n    (the control marker), fails to identify the option, and silently drops\n    every sub-input value for it.\n\n    Affects 38 stock API nodes that pair a seed with a dynamic combo:\n    Bria*, ByteDance*, Grok*, Kling*, Meshy*, Recraft*, Reve*, Vidu*, Wan2*,\n    HappyHorse*, Tencent*, Quiver*.\n    \"\"\"\n\n    def test_dynamic_combo_selector_reads_from_filtered_slot(self):\n        object_info = {\n            \"VulnerableNode\": {\n                \"input\": {\n                    \"required\": {\n                        \"seed\": [\"INT\", {\"default\": 0, \"control_after_generate\": True}],\n                        \"shape\": [\n                            \"COMFY_DYNAMICCOMBO_V3\",\n                            {\n                                \"options\": [\n                                    {\"key\": \"circle\", \"inputs\": {\"required\": {\"radius\": [\"FLOAT\"]}}},\n                                    {\"key\": \"square\", \"inputs\": {\"required\": {\"side\": [\"FLOAT\"]}}},\n                                ]\n                            },\n                        ],\n                    }\n                },\n                \"input_order\": {\"required\": [\"seed\", \"shape\"]},\n                \"output_node\": True,\n                \"display_name\": \"VN\",\n            }\n        }\n        workflow = {\n            \"nodes\": [\n                {\n                    \"id\": 1,\n                    \"type\": \"VulnerableNode\",\n                    \"inputs\": [],\n                    \"outputs\": [],\n                    # seed, control_marker, shape selector, then sub-input\n                    \"widgets_values\": [42, \"randomize\", \"square\", 10.0],\n                    \"mode\": 0,\n                }\n            ],\n            \"links\": [],\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        inputs = result[\"1\"][\"inputs\"]\n        assert inputs[\"seed\"] == 42\n        assert inputs[\"shape\"] == \"square\"\n        # Without the fix the sub-input was silently dropped.\n        assert inputs[\"shape.side\"] == 10.0\n\n\nclass TestDynamicPrompts:\n    \"\"\"Port of frontend's processDynamicPrompt behavior (formatUtil.ts).\n\n    Tests pin ``random.choice`` deterministically; the runtime behavior is\n    genuinely random, matching the frontend's ``Math.random()`` semantics.\n    \"\"\"\n\n    # -- Pure algorithm --------------------------------------------------\n\n    def test_no_braces_passes_through(self):\n        assert process_dynamic_prompt(\"abcdef\") == \"abcdef\"\n        assert process_dynamic_prompt(\"\") == \"\"\n\n    def test_strips_line_comments(self):\n        # // to end of line\n        assert process_dynamic_prompt(\"abc // a comment\\nrest\") == \"abc \\nrest\"\n\n    def test_strips_block_comments(self):\n        # /* ... */ across or within lines\n        assert process_dynamic_prompt(\"/*\\nStart\\n*/Hello /* mid */ world\") == \"Hello  world\"\n\n    def test_picks_one_option_per_group(self):\n        with patch(\"comfy_cli.workflow_to_api.random.choice\", side_effect=lambda opts: opts[0]):\n            assert process_dynamic_prompt(\"{option1|option2}\") == \"option1\"\n        with patch(\"comfy_cli.workflow_to_api.random.choice\", side_effect=lambda opts: opts[-1]):\n            assert process_dynamic_prompt(\"{option1|option2}\") == \"option2\"\n\n    def test_handles_empty_alternatives(self):\n        # Trailing empty\n        with patch(\"comfy_cli.workflow_to_api.random.choice\", side_effect=lambda opts: opts[-1]):\n            assert process_dynamic_prompt(\"{a|}\") == \"\"\n        # Leading empty\n        with patch(\"comfy_cli.workflow_to_api.random.choice\", side_effect=lambda opts: opts[0]):\n            assert process_dynamic_prompt(\"{|a}\") == \"\"\n        # All empty\n        with patch(\"comfy_cli.workflow_to_api.random.choice\", side_effect=lambda opts: opts[0]):\n            assert process_dynamic_prompt(\"{||}\") == \"\"\n\n    def test_handles_nested_groups(self):\n        # Always pick first → outer 'a'\n        with patch(\"comfy_cli.workflow_to_api.random.choice\", side_effect=lambda opts: opts[0]):\n            assert process_dynamic_prompt(\"{a|{b|{c|d}}}\") == \"a\"\n        # Always pick last → innermost 'd'\n        with patch(\"comfy_cli.workflow_to_api.random.choice\", side_effect=lambda opts: opts[-1]):\n            assert process_dynamic_prompt(\"{a|{b|{c|d}}}\") == \"d\"\n\n    def test_escapes_preserve_literal_characters(self):\n        # Escaped braces remain literal\n        assert process_dynamic_prompt(\"\\\\{a|b\\\\}\") == \"{a|b}\"\n        # Escaped pipe outside group\n        assert process_dynamic_prompt(\"a\\\\|b\") == \"a|b\"\n        # Escapes inside group survive\n        with patch(\"comfy_cli.workflow_to_api.random.choice\", side_effect=lambda opts: opts[0]):\n            assert process_dynamic_prompt(\"{\\\\{escaped\\\\}\\\\|escaped pipe}\") == \"{escaped}|escaped pipe\"\n\n    def test_unterminated_group_degrades_gracefully(self):\n        # Frontend never throws on malformed input; we match that.\n        with patch(\"comfy_cli.workflow_to_api.random.choice\", side_effect=lambda opts: opts[0]):\n            assert process_dynamic_prompt(\"{option1|option2|{nested1|nested2\") == \"option1\"\n\n    def test_multiple_groups_in_one_string(self):\n        with patch(\"comfy_cli.workflow_to_api.random.choice\", side_effect=lambda opts: opts[1]):\n            assert process_dynamic_prompt(\"1{a|b|c}2{d|e|f}3\") == \"1b2e3\"\n\n    # -- Integration via convert_ui_to_api -------------------------------\n\n    OI = {\n        \"CLIPTextEncode\": {\n            \"input\": {\n                \"required\": {\n                    \"text\": [\"STRING\", {\"multiline\": True, \"dynamicPrompts\": True}],\n                    \"clip\": [\"CLIP\"],\n                }\n            },\n            \"input_order\": {\"required\": [\"text\", \"clip\"]},\n            \"output_node\": False,\n            \"output\": [\"CONDITIONING\"],\n            \"display_name\": \"CLIP Text Encode\",\n        },\n        \"PreviewImage\": {\n            \"input\": {\"required\": {\"images\": [\"IMAGE\"]}},\n            \"input_order\": {\"required\": [\"images\"]},\n            \"output_node\": True,\n            \"display_name\": \"Preview Image\",\n        },\n        \"PlainText\": {\n            \"input\": {\"required\": {\"text\": [\"STRING\", {}]}},\n            \"input_order\": {\"required\": [\"text\"]},\n            \"output_node\": True,\n            \"display_name\": \"Plain Text\",\n        },\n    }\n\n    def test_clip_text_encode_resolves_groups(self):\n        with patch(\"comfy_cli.workflow_to_api.random.choice\", side_effect=lambda opts: opts[0]):\n            workflow = {\n                \"nodes\": [\n                    {\n                        \"id\": 1,\n                        \"type\": \"CLIPTextEncode\",\n                        \"inputs\": [{\"name\": \"clip\", \"link\": None}],\n                        \"outputs\": [{\"links\": [10]}],\n                        \"widgets_values\": [\"a {red|blue} hat\"],\n                        \"mode\": 0,\n                    },\n                    {\n                        \"id\": 2,\n                        \"type\": \"PreviewImage\",\n                        \"inputs\": [{\"name\": \"images\", \"link\": 10}],\n                        \"outputs\": [],\n                        \"mode\": 0,\n                    },\n                ],\n                \"links\": [[10, 1, 0, 2, 0, \"IMAGE\"]],\n            }\n            result = convert_ui_to_api(workflow, self.OI)\n            assert result[\"1\"][\"inputs\"][\"text\"] == \"a red hat\"\n\n    def test_widget_without_dynamic_prompts_flag_left_alone(self):\n        # PlainText.text does NOT declare dynamicPrompts → literal passthrough.\n        workflow = {\n            \"nodes\": [\n                {\n                    \"id\": 1,\n                    \"type\": \"PlainText\",\n                    \"inputs\": [],\n                    \"outputs\": [],\n                    \"widgets_values\": [\"a {red|blue} hat\"],\n                    \"mode\": 0,\n                },\n            ],\n            \"links\": [],\n        }\n        result = convert_ui_to_api(workflow, self.OI)\n        assert result[\"1\"][\"inputs\"][\"text\"] == \"a {red|blue} hat\"\n\n    def test_non_string_value_passes_through_unchanged(self):\n        # Numeric values on a dynamicPrompts input shouldn't be regex'd\n        workflow = {\n            \"nodes\": [\n                {\n                    \"id\": 1,\n                    \"type\": \"CLIPTextEncode\",\n                    \"inputs\": [{\"name\": \"clip\", \"link\": None}],\n                    \"outputs\": [{\"links\": [10]}],\n                    \"widgets_values\": [42],\n                    \"mode\": 0,\n                },\n                {\n                    \"id\": 2,\n                    \"type\": \"PreviewImage\",\n                    \"inputs\": [{\"name\": \"images\", \"link\": 10}],\n                    \"outputs\": [],\n                    \"mode\": 0,\n                },\n            ],\n            \"links\": [[10, 1, 0, 2, 0, \"IMAGE\"]],\n        }\n        result = convert_ui_to_api(workflow, self.OI)\n        assert result[\"1\"][\"inputs\"][\"text\"] == 42\n\n    def test_random_choice_is_deterministic_under_seed(self):\n        # Sanity: seeding the global RNG fixes the choice — useful for the\n        # rare downstream test/script that wants reproducible runs.\n        random.seed(0)\n        first = process_dynamic_prompt(\"{alpha|beta|gamma}\")\n        random.seed(0)\n        second = process_dynamic_prompt(\"{alpha|beta|gamma}\")\n        assert first == second\n        assert first in {\"alpha\", \"beta\", \"gamma\"}\n\n\nclass TestFixtureParity:\n    \"\"\"Regression test against a real workflow + the exact API output that\n    ComfyUI's /workflow/convert endpoint produced for it.\n\n    Regenerate the fixtures by running a live ComfyUI with Seth Robinson's\n    /workflow/convert node and POSTing the UI JSON to the endpoint.\n    \"\"\"\n\n    def test_sd15_workflow_matches_reference(self):\n        ui = json.loads((FIXTURES / \"sd15_ui_workflow.json\").read_text())\n        object_info = json.loads((FIXTURES / \"sd15_object_info.json\").read_text())\n        expected = json.loads((FIXTURES / \"sd15_expected_api.json\").read_text())\n        assert convert_ui_to_api(ui, object_info) == expected\n\n\nclass TestSubgraphExpansion:\n    def test_simple_subgraph_expansion(self, object_info):\n        sg_uuid = \"11111111-2222-3333-4444-555555555555\"\n        # Outer workflow: an EmptyLatentImage feeds a subgraph instance whose\n        # internal pipeline ends with a PreviewImage. After expansion the\n        # PreviewImage should appear with a prefixed id (\"100:50\").\n        workflow = {\n            \"nodes\": [\n                _node(7, \"EmptyLatentImage\", outputs=[{\"links\": [200]}], widgets=[512, 512, 1]),\n                # The subgraph instance — its `type` is the UUID.\n                {\n                    \"id\": 100,\n                    \"type\": sg_uuid,\n                    \"inputs\": [{\"name\": \"incoming\", \"link\": 200}],\n                    \"outputs\": [],\n                    \"mode\": 0,\n                },\n            ],\n            \"links\": [[200, 7, 0, 100, 0, \"LATENT\"]],\n            \"definitions\": {\n                \"subgraphs\": [\n                    {\n                        \"id\": sg_uuid,\n                        \"name\": \"MySubgraph\",\n                        \"inputs\": [{\"name\": \"incoming\", \"linkIds\": [301]}],\n                        \"outputs\": [],\n                        \"nodes\": [\n                            {\n                                \"id\": 50,\n                                \"type\": \"PreviewImage\",\n                                \"inputs\": [{\"name\": \"images\", \"link\": 301}],\n                                \"outputs\": [],\n                                \"mode\": 0,\n                            },\n                        ],\n                        \"links\": [\n                            {\n                                \"id\": 301,\n                                \"origin_id\": -10,  # subgraph input proxy\n                                \"origin_slot\": 0,\n                                \"target_id\": 50,\n                                \"target_slot\": 0,\n                                \"type\": \"LATENT\",\n                            },\n                        ],\n                    }\n                ]\n            },\n        }\n        result = convert_ui_to_api(workflow, object_info)\n        # The subgraph instance itself is gone; internal node appears with prefix.\n        assert \"100\" not in result\n        assert \"100:50\" in result\n        # Link from the external EmptyLatentImage was retargeted at the internal node.\n        assert result[\"100:50\"][\"inputs\"][\"images\"] == [\"7\", 0]\n"
  },
  {
    "path": "tests/comfy_cli/test_workspace_manager.py",
    "content": "import os\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\n\nfrom comfy_cli.workspace_manager import (\n    WorkspaceType,\n    _find_comfyui_root,\n    _has_comfyui_markers,\n    _paths_match,\n    check_comfy_repo,\n)\n\n\nclass TestPathsMatch:\n    def test_identical_paths(self, tmp_path):\n        d = tmp_path / \"comfy\"\n        d.mkdir()\n        assert _paths_match(str(d), str(d))\n\n    def test_symlink_to_same_dir(self, tmp_path):\n        real = tmp_path / \"real\"\n        real.mkdir()\n        link = tmp_path / \"link\"\n        link.symlink_to(real)\n        assert _paths_match(str(real), str(link))\n\n    def test_different_paths(self, tmp_path):\n        a = tmp_path / \"a\"\n        b = tmp_path / \"b\"\n        a.mkdir()\n        b.mkdir()\n        assert not _paths_match(str(a), str(b))\n\n    def test_nonexistent_paths_same(self):\n        assert _paths_match(\"/nonexistent/same\", \"/nonexistent/same\")\n\n    def test_nonexistent_paths_different(self):\n        assert not _paths_match(\"/nonexistent/a\", \"/nonexistent/b\")\n\n    def test_trailing_slash(self, tmp_path):\n        d = tmp_path / \"comfy\"\n        d.mkdir()\n        assert _paths_match(str(d), str(d) + \"/\")\n\n    def test_dot_components(self, tmp_path):\n        d = tmp_path / \"comfy\"\n        d.mkdir()\n        assert _paths_match(str(d), str(d) + \"/./\")\n\n    def test_parent_component(self, tmp_path):\n        d = tmp_path / \"comfy\"\n        d.mkdir()\n        sub = d / \"sub\"\n        sub.mkdir()\n        assert _paths_match(str(d), str(sub) + \"/..\")\n\n    def test_one_exists_one_not(self, tmp_path):\n        d = tmp_path / \"exists\"\n        d.mkdir()\n        # samefile will raise because the second path doesn't exist;\n        # fallback compares realpath strings, which will differ\n        assert not _paths_match(str(d), \"/nonexistent/path\")\n\n    def test_double_symlink(self, tmp_path):\n        real = tmp_path / \"real\"\n        real.mkdir()\n        link1 = tmp_path / \"link1\"\n        link1.symlink_to(real)\n        link2 = tmp_path / \"link2\"\n        link2.symlink_to(link1)\n        assert _paths_match(str(link1), str(link2))\n        assert _paths_match(str(real), str(link2))\n\n\n_ALL_MARKERS = [\"main.py\", \"comfy\", \"nodes.py\", \"comfy_extras\", \"comfy_api\"]\n_MARKER_DIRS = {\"comfy\", \"comfy_extras\", \"comfy_api\"}\n\n\ndef _create_comfyui_markers(path, markers=None):\n    \"\"\"Create ComfyUI marker files/directories under *path*.\"\"\"\n    if markers is None:\n        markers = _ALL_MARKERS\n    for m in markers:\n        p = path / m\n        if m in _MARKER_DIRS:\n            p.mkdir(exist_ok=True)\n        else:\n            p.touch()\n\n\nclass TestHasComfyuiMarkers:\n    def test_all_five_markers(self, tmp_path):\n        _create_comfyui_markers(tmp_path)\n        assert _has_comfyui_markers(str(tmp_path)) is True\n\n    @pytest.mark.parametrize(\"omit\", _ALL_MARKERS)\n    def test_any_four_of_five_sufficient(self, tmp_path, omit):\n        remaining = [m for m in _ALL_MARKERS if m != omit]\n        _create_comfyui_markers(tmp_path, remaining)\n        assert _has_comfyui_markers(str(tmp_path)) is True\n\n    @pytest.mark.parametrize(\n        \"present\",\n        [\n            [\"main.py\", \"comfy\", \"nodes.py\"],\n            [\"comfy\", \"comfy_extras\", \"comfy_api\"],\n            [\"main.py\", \"nodes.py\", \"comfy_api\"],\n        ],\n    )\n    def test_three_markers_insufficient(self, tmp_path, present):\n        _create_comfyui_markers(tmp_path, present)\n        assert _has_comfyui_markers(str(tmp_path)) is False\n\n    def test_empty_directory(self, tmp_path):\n        assert _has_comfyui_markers(str(tmp_path)) is False\n\n    def test_nonexistent_path(self):\n        assert _has_comfyui_markers(\"/nonexistent/path/xyz\") is False\n\n\ndef _make_manager(*, use_here=None, specified_workspace=None, use_recent=None):\n    \"\"\"Create a fresh WorkspaceManager with reset singleton.\"\"\"\n    from comfy_cli.workspace_manager import WorkspaceManager\n\n    WorkspaceManager._instances = {}\n    mgr = WorkspaceManager()\n    mgr.use_here = use_here\n    mgr.use_recent = use_recent\n    mgr.specified_workspace = specified_workspace\n    return mgr\n\n\ndef _mock_config(mgr, default_workspace=None, recent_workspace=None):\n    \"\"\"Replace config_manager with a mock that returns the given values.\"\"\"\n    mock_cm = MagicMock()\n\n    def _get(key):\n        from comfy_cli import constants\n\n        if key == constants.CONFIG_KEY_DEFAULT_WORKSPACE:\n            return default_workspace\n        if key == constants.CONFIG_KEY_RECENT_WORKSPACE:\n            return recent_workspace\n        return None\n\n    mock_cm.get.side_effect = _get\n    mgr.config_manager = mock_cm\n    return mock_cm\n\n\nclass TestFindComfyuiRoot:\n    \"\"\"Tests for the upward directory walk in _find_comfyui_root.\"\"\"\n\n    def test_markers_at_given_path(self, tmp_path):\n        _create_comfyui_markers(tmp_path)\n        assert _find_comfyui_root(str(tmp_path)) == str(tmp_path)\n\n    def test_walks_up_to_parent_with_markers(self, tmp_path):\n        _create_comfyui_markers(tmp_path)\n        subdir = tmp_path / \"custom_nodes\" / \"MyNode\"\n        subdir.mkdir(parents=True)\n        assert _find_comfyui_root(str(subdir)) == str(tmp_path)\n\n    def test_walks_up_multiple_levels(self, tmp_path):\n        _create_comfyui_markers(tmp_path)\n        deep = tmp_path / \"custom_nodes\" / \"MyNode\" / \"lib\" / \"utils\"\n        deep.mkdir(parents=True)\n        assert _find_comfyui_root(str(deep)) == str(tmp_path)\n\n    def test_no_markers_anywhere(self, tmp_path):\n        subdir = tmp_path / \"a\" / \"b\"\n        subdir.mkdir(parents=True)\n        assert _find_comfyui_root(str(subdir)) is None\n\n    def test_returns_nearest_root(self, tmp_path):\n        \"\"\"Nested ComfyUI installs: returns the closest (deepest) match.\"\"\"\n        outer = tmp_path / \"outer\"\n        inner = outer / \"inner\"\n        inner.mkdir(parents=True)\n        _create_comfyui_markers(outer)\n        _create_comfyui_markers(inner)\n        subdir = inner / \"custom_nodes\"\n        subdir.mkdir()\n        assert _find_comfyui_root(str(subdir)) == str(inner)\n\n\nclass TestCheckComfyRepoFallback:\n    \"\"\"Tests for the marker-based fallback in check_comfy_repo.\"\"\"\n\n    def test_nonexistent_path(self):\n        found, path = check_comfy_repo(\"/nonexistent/path/xyz\")\n        assert found is False\n        assert path is None\n\n    def test_non_git_dir_with_all_markers(self, tmp_path):\n        _create_comfyui_markers(tmp_path)\n        found, path = check_comfy_repo(str(tmp_path))\n        assert found is True\n        assert path == str(tmp_path)\n\n    def test_non_git_dir_with_four_markers(self, tmp_path):\n        _create_comfyui_markers(tmp_path, [\"main.py\", \"comfy\", \"nodes.py\", \"comfy_api\"])\n        found, path = check_comfy_repo(str(tmp_path))\n        assert found is True\n        assert path == str(tmp_path)\n\n    def test_non_git_dir_insufficient_markers(self, tmp_path):\n        _create_comfyui_markers(tmp_path, [\"main.py\", \"comfy\", \"nodes.py\"])\n        found, path = check_comfy_repo(str(tmp_path))\n        assert found is False\n        assert path is None\n\n    def test_non_git_empty_dir(self, tmp_path):\n        found, path = check_comfy_repo(str(tmp_path))\n        assert found is False\n        assert path is None\n\n    def test_returned_path_is_absolute(self, tmp_path):\n        \"\"\"Path with '..' components is resolved to a clean absolute path.\"\"\"\n        _create_comfyui_markers(tmp_path)\n        subdir = tmp_path / \"sub\"\n        subdir.mkdir()\n        dotdot_path = os.path.join(str(subdir), \"..\")\n        found, path = check_comfy_repo(dotdot_path)\n        assert found is True\n        assert os.path.isabs(path)\n        assert \"..\" not in path\n\n    def test_subdirectory_walks_up_to_root(self, tmp_path):\n        \"\"\"check_comfy_repo from a subdirectory resolves to the ComfyUI root.\"\"\"\n        _create_comfyui_markers(tmp_path)\n        subdir = tmp_path / \"custom_nodes\" / \"MyNode\"\n        subdir.mkdir(parents=True)\n        found, path = check_comfy_repo(str(subdir))\n        assert found is True\n        assert path == str(tmp_path)\n\n    def test_fork_repo_with_markers_detected(self, tmp_path):\n        \"\"\"Git repo with non-ComfyUI remote + markers → detected via fallback.\"\"\"\n        import git as gitmodule\n\n        repo = gitmodule.Repo.init(str(tmp_path))\n        repo.create_remote(\"origin\", \"https://github.com/someone/ComfyUI-fork\")\n        (tmp_path / \"README.md\").write_text(\"fork\")\n        repo.index.add([\"README.md\"])\n        repo.index.commit(\"init\")\n\n        _create_comfyui_markers(tmp_path)\n\n        found, path = check_comfy_repo(str(tmp_path))\n        assert found is True\n        assert path == str(tmp_path)\n\n    def test_fork_repo_without_markers_not_detected(self, tmp_path):\n        \"\"\"Git repo with non-ComfyUI remote and no markers → not detected.\"\"\"\n        import git as gitmodule\n\n        repo = gitmodule.Repo.init(str(tmp_path))\n        repo.create_remote(\"origin\", \"https://github.com/someone/other-project\")\n        (tmp_path / \"README.md\").write_text(\"other\")\n        repo.index.add([\"README.md\"])\n        repo.index.commit(\"init\")\n\n        found, path = check_comfy_repo(str(tmp_path))\n        assert found is False\n        assert path is None\n\n\nclass TestStep1Workspace:\n    def test_workspace_flag_takes_priority(self):\n        mgr = _make_manager(specified_workspace=\"/opt/comfy\")\n        _mock_config(mgr)\n        path, ws_type = mgr.get_workspace_path()\n        assert ws_type == WorkspaceType.SPECIFIED\n        assert path == \"/opt/comfy\"\n\n    @patch(\"comfy_cli.workspace_manager.check_comfy_repo\")\n    @patch(\"comfy_cli.workspace_manager.os.getcwd\")\n    def test_workspace_overrides_cwd_matching_default(self, mock_getcwd, mock_check):\n        \"\"\"--workspace wins even when cwd is the default workspace.\"\"\"\n        mock_getcwd.return_value = \"/home/user/comfy/ComfyUI\"\n        mock_check.return_value = (True, \"/home/user/comfy/ComfyUI\")\n\n        mgr = _make_manager(specified_workspace=\"/other/ComfyUI\")\n        _mock_config(mgr, default_workspace=\"/home/user/comfy/ComfyUI\")\n\n        path, ws_type = mgr.get_workspace_path()\n        assert ws_type == WorkspaceType.SPECIFIED\n        assert path == \"/other/ComfyUI\"\n\n\nclass TestStep3Here:\n    @patch(\"comfy_cli.workspace_manager.check_comfy_repo\")\n    @patch(\"comfy_cli.workspace_manager.os.getcwd\")\n    def test_here_flag_forces_current_dir_even_if_matches_default(self, mock_getcwd, mock_check):\n        \"\"\"--here always returns CURRENT_DIR, even when cwd IS the default.\"\"\"\n        mock_getcwd.return_value = \"/home/user/comfy/ComfyUI\"\n        mock_check.return_value = (True, \"/home/user/comfy/ComfyUI\")\n\n        mgr = _make_manager(use_here=True)\n        _mock_config(mgr, default_workspace=\"/home/user/comfy/ComfyUI\")\n\n        path, ws_type = mgr.get_workspace_path()\n        assert ws_type == WorkspaceType.CURRENT_DIR\n        assert path == \"/home/user/comfy/ComfyUI\"\n\n    @patch(\"comfy_cli.workspace_manager.check_comfy_repo\")\n    @patch(\"comfy_cli.workspace_manager.os.getcwd\")\n    def test_here_flag_non_comfy_dir_appends_comfyui(self, mock_getcwd, mock_check):\n        \"\"\"--here in a non-ComfyUI dir returns cwd/ComfyUI.\"\"\"\n        mock_getcwd.return_value = \"/home/user/projects\"\n        mock_check.return_value = (False, None)\n\n        mgr = _make_manager(use_here=True)\n        _mock_config(mgr)\n\n        path, ws_type = mgr.get_workspace_path()\n        assert ws_type == WorkspaceType.CURRENT_DIR\n        assert path == os.path.join(\"/home/user/projects\", \"ComfyUI\")\n\n\nclass TestStep4AutoDetect:\n    @patch(\"comfy_cli.workspace_manager.check_comfy_repo\")\n    @patch(\"comfy_cli.workspace_manager.os.getcwd\")\n    def test_cwd_matches_default_returns_default_type(self, mock_getcwd, mock_check):\n        \"\"\"Core fix: cwd is the configured default workspace -> DEFAULT.\"\"\"\n        mock_getcwd.return_value = \"/home/user/comfy/ComfyUI\"\n        mock_check.return_value = (True, \"/home/user/comfy/ComfyUI\")\n\n        mgr = _make_manager(use_here=None)\n        _mock_config(mgr, default_workspace=\"/home/user/comfy/ComfyUI\")\n\n        with patch(\"comfy_cli.workspace_manager._paths_match\", return_value=True):\n            path, ws_type = mgr.get_workspace_path()\n\n        assert ws_type == WorkspaceType.DEFAULT\n        assert path == \"/home/user/comfy/ComfyUI\"\n\n    @patch(\"comfy_cli.workspace_manager.check_comfy_repo\")\n    @patch(\"comfy_cli.workspace_manager.os.getcwd\")\n    def test_cwd_different_repo_returns_current_dir(self, mock_getcwd, mock_check):\n        \"\"\"cwd is a ComfyUI repo but NOT the default -> CURRENT_DIR.\"\"\"\n        mock_getcwd.return_value = \"/home/user/other/ComfyUI\"\n        mock_check.return_value = (True, \"/home/user/other/ComfyUI\")\n\n        mgr = _make_manager(use_here=None)\n        _mock_config(mgr, default_workspace=\"/home/user/comfy/ComfyUI\")\n\n        with patch(\"comfy_cli.workspace_manager._paths_match\", return_value=False):\n            path, ws_type = mgr.get_workspace_path()\n\n        assert ws_type == WorkspaceType.CURRENT_DIR\n        assert path == \"/home/user/other/ComfyUI\"\n\n    @patch(\"comfy_cli.workspace_manager.check_comfy_repo\")\n    @patch(\"comfy_cli.workspace_manager.os.getcwd\")\n    def test_cwd_repo_no_default_configured(self, mock_getcwd, mock_check):\n        \"\"\"cwd is a ComfyUI repo, no default configured -> CURRENT_DIR.\"\"\"\n        mock_getcwd.return_value = \"/home/user/comfy/ComfyUI\"\n        mock_check.return_value = (True, \"/home/user/comfy/ComfyUI\")\n\n        mgr = _make_manager(use_here=None)\n        _mock_config(mgr, default_workspace=None)\n\n        path, ws_type = mgr.get_workspace_path()\n        assert ws_type == WorkspaceType.CURRENT_DIR\n        assert path == \"/home/user/comfy/ComfyUI\"\n\n    @patch(\"comfy_cli.workspace_manager.check_comfy_repo\")\n    @patch(\"comfy_cli.workspace_manager.os.getcwd\")\n    def test_cwd_repo_empty_default_returns_current_dir(self, mock_getcwd, mock_check):\n        \"\"\"default_workspace is empty string -> treated as not configured.\"\"\"\n        mock_getcwd.return_value = \"/home/user/comfy/ComfyUI\"\n        mock_check.return_value = (True, \"/home/user/comfy/ComfyUI\")\n\n        mgr = _make_manager(use_here=None)\n        _mock_config(mgr, default_workspace=\"\")\n\n        path, ws_type = mgr.get_workspace_path()\n        assert ws_type == WorkspaceType.CURRENT_DIR\n\n    @patch(\"comfy_cli.workspace_manager.check_comfy_repo\")\n    @patch(\"comfy_cli.workspace_manager.os.getcwd\")\n    def test_paths_match_called_with_correct_args(self, mock_getcwd, mock_check):\n        \"\"\"Verify _paths_match receives resolved path and default_workspace.\"\"\"\n        mock_getcwd.return_value = \"/cwd\"\n        mock_check.return_value = (True, \"/resolved/ComfyUI\")\n\n        mgr = _make_manager(use_here=None)\n        _mock_config(mgr, default_workspace=\"/configured/default\")\n\n        with patch(\"comfy_cli.workspace_manager._paths_match\", return_value=False) as mock_pm:\n            mgr.get_workspace_path()\n\n        mock_pm.assert_called_once_with(\"/resolved/ComfyUI\", \"/configured/default\")\n\n\nclass TestNoHereSkipsStep4:\n    @patch(\"comfy_cli.workspace_manager.check_comfy_repo\")\n    @patch(\"comfy_cli.workspace_manager.os.getcwd\")\n    def test_no_here_skips_cwd_detection(self, mock_getcwd, mock_check):\n        \"\"\"--no-here (use_here=False) skips step 4 entirely, falls to step 5.\"\"\"\n        mock_getcwd.return_value = \"/home/user/comfy/ComfyUI\"\n        mock_check.return_value = (True, \"/home/user/comfy/ComfyUI\")\n\n        mgr = _make_manager(use_here=False)\n        _mock_config(mgr, default_workspace=\"/home/user/comfy/ComfyUI\")\n\n        path, ws_type = mgr.get_workspace_path()\n\n        assert ws_type == WorkspaceType.DEFAULT\n        assert path == \"/home/user/comfy/ComfyUI\"\n        # getcwd should never be called because step 4 is skipped\n        mock_getcwd.assert_not_called()\n\n\nclass TestStep5ConfiguredDefault:\n    @patch(\"comfy_cli.workspace_manager.check_comfy_repo\")\n    @patch(\"comfy_cli.workspace_manager.os.getcwd\")\n    def test_not_comfy_repo_falls_through_to_default(self, mock_getcwd, mock_check):\n        \"\"\"cwd is NOT a ComfyUI repo -> falls through to configured default.\"\"\"\n        mock_getcwd.return_value = \"/home/user/projects\"\n        mock_check.side_effect = lambda path: (\n            (True, \"/home/user/comfy/ComfyUI\") if path == \"/home/user/comfy/ComfyUI\" else (False, None)\n        )\n\n        mgr = _make_manager(use_here=None)\n        _mock_config(mgr, default_workspace=\"/home/user/comfy/ComfyUI\")\n\n        path, ws_type = mgr.get_workspace_path()\n        assert ws_type == WorkspaceType.DEFAULT\n        assert path == \"/home/user/comfy/ComfyUI\"\n\n\nclass TestStep6RecentFallback:\n    @patch(\"comfy_cli.workspace_manager.check_comfy_repo\")\n    @patch(\"comfy_cli.workspace_manager.os.getcwd\")\n    def test_no_default_falls_to_recent(self, mock_getcwd, mock_check):\n        \"\"\"No default configured, valid recent workspace -> RECENT.\"\"\"\n        mock_getcwd.return_value = \"/home/user/projects\"\n        mock_check.side_effect = lambda path: (\n            (True, \"/home/user/recent/ComfyUI\") if path == \"/home/user/recent/ComfyUI\" else (False, None)\n        )\n\n        mgr = _make_manager(use_here=None, use_recent=None)\n        _mock_config(\n            mgr,\n            default_workspace=None,\n            recent_workspace=\"/home/user/recent/ComfyUI\",\n        )\n\n        path, ws_type = mgr.get_workspace_path()\n        assert ws_type == WorkspaceType.RECENT\n        assert path == \"/home/user/recent/ComfyUI\"\n\n\nclass TestStep7FallbackDefault:\n    @patch(\"comfy_cli.workspace_manager.utils.get_not_user_set_default_workspace\")\n    @patch(\"comfy_cli.workspace_manager.check_comfy_repo\", return_value=(False, None))\n    @patch(\"comfy_cli.workspace_manager.os.getcwd\", return_value=\"/tmp/random\")\n    def test_all_fallbacks_exhausted(self, _cwd, _check, mock_fallback):\n        \"\"\"Nothing configured, cwd not a repo -> system fallback DEFAULT.\"\"\"\n        mock_fallback.return_value = \"/home/user/comfy\"\n        mgr = _make_manager(use_here=None, use_recent=None)\n        _mock_config(mgr, default_workspace=None, recent_workspace=None)\n\n        path, ws_type = mgr.get_workspace_path()\n        assert ws_type == WorkspaceType.DEFAULT\n        assert path == \"/home/user/comfy\"\n\n\nclass TestFullIntegration:\n    \"\"\"Create a real git repo that looks like ComfyUI (with the right remote)\n    and exercise the entire get_workspace_path flow with no mocked internals.\n    Only os.getcwd and ConfigManager are faked.\"\"\"\n\n    @staticmethod\n    def _create_comfy_repo(path):\n        \"\"\"Create a bare-minimum git repo with a ComfyUI remote.\"\"\"\n        import git as gitmodule\n\n        repo = gitmodule.Repo.init(path)\n        repo.create_remote(\"origin\", \"https://github.com/comfyanonymous/ComfyUI\")\n        # Need at least one commit for repo to be fully valid\n        readme = os.path.join(path, \"main.py\")\n        with open(readme, \"w\") as f:\n            f.write(\"# ComfyUI\\n\")\n        repo.index.add([\"main.py\"])\n        repo.index.commit(\"init\")\n        return repo\n\n    def test_cwd_is_default_workspace_real_repo(self, tmp_path):\n        \"\"\"Bug repro: cd into default workspace -> must return DEFAULT.\"\"\"\n        comfy_dir = str(tmp_path / \"ComfyUI\")\n        self._create_comfy_repo(comfy_dir)\n\n        mgr = _make_manager(use_here=None)\n        _mock_config(mgr, default_workspace=comfy_dir)\n\n        with patch(\"comfy_cli.workspace_manager.os.getcwd\", return_value=comfy_dir):\n            path, ws_type = mgr.get_workspace_path()\n\n        assert ws_type == WorkspaceType.DEFAULT\n        assert path == comfy_dir\n\n    def test_cwd_is_default_workspace_via_symlink(self, tmp_path):\n        \"\"\"Default stored as symlink, cwd is the real path -> DEFAULT.\"\"\"\n        real_dir = tmp_path / \"real_comfy\"\n        self._create_comfy_repo(str(real_dir))\n        link = tmp_path / \"comfy_link\"\n        link.symlink_to(real_dir)\n\n        mgr = _make_manager(use_here=None)\n        _mock_config(mgr, default_workspace=str(link))\n\n        with patch(\"comfy_cli.workspace_manager.os.getcwd\", return_value=str(real_dir)):\n            path, ws_type = mgr.get_workspace_path()\n\n        assert ws_type == WorkspaceType.DEFAULT\n\n    def test_cwd_is_subdir_of_default_workspace(self, tmp_path):\n        \"\"\"cd into custom_nodes/ inside default workspace -> DEFAULT.\"\"\"\n        comfy_dir = tmp_path / \"ComfyUI\"\n        self._create_comfy_repo(str(comfy_dir))\n        subdir = comfy_dir / \"custom_nodes\"\n        subdir.mkdir()\n\n        mgr = _make_manager(use_here=None)\n        _mock_config(mgr, default_workspace=str(comfy_dir))\n\n        with patch(\"comfy_cli.workspace_manager.os.getcwd\", return_value=str(subdir)):\n            path, ws_type = mgr.get_workspace_path()\n\n        assert ws_type == WorkspaceType.DEFAULT\n        assert path == str(comfy_dir)\n\n    def test_two_repos_cwd_in_non_default(self, tmp_path):\n        \"\"\"Two ComfyUI repos exist; cwd is in the non-default one -> CURRENT_DIR.\"\"\"\n        default_dir = tmp_path / \"default_comfy\"\n        other_dir = tmp_path / \"other_comfy\"\n        self._create_comfy_repo(str(default_dir))\n        self._create_comfy_repo(str(other_dir))\n\n        mgr = _make_manager(use_here=None)\n        _mock_config(mgr, default_workspace=str(default_dir))\n\n        with patch(\"comfy_cli.workspace_manager.os.getcwd\", return_value=str(other_dir)):\n            path, ws_type = mgr.get_workspace_path()\n\n        assert ws_type == WorkspaceType.CURRENT_DIR\n        assert path == str(other_dir)\n\n    def test_default_workspace_trailing_slash(self, tmp_path):\n        \"\"\"Config has trailing slash, git working_dir doesn't -> DEFAULT.\"\"\"\n        comfy_dir = str(tmp_path / \"ComfyUI\")\n        self._create_comfy_repo(comfy_dir)\n\n        mgr = _make_manager(use_here=None)\n        _mock_config(mgr, default_workspace=comfy_dir + \"/\")\n\n        with patch(\"comfy_cli.workspace_manager.os.getcwd\", return_value=comfy_dir):\n            path, ws_type = mgr.get_workspace_path()\n\n        assert ws_type == WorkspaceType.DEFAULT\n\n    def test_here_flag_overrides_even_with_real_repo(self, tmp_path):\n        \"\"\"--here forces CURRENT_DIR even in a real default workspace repo.\"\"\"\n        comfy_dir = str(tmp_path / \"ComfyUI\")\n        self._create_comfy_repo(comfy_dir)\n\n        mgr = _make_manager(use_here=True)\n        _mock_config(mgr, default_workspace=comfy_dir)\n\n        with patch(\"comfy_cli.workspace_manager.os.getcwd\", return_value=comfy_dir):\n            path, ws_type = mgr.get_workspace_path()\n\n        assert ws_type == WorkspaceType.CURRENT_DIR\n\n    def test_not_in_any_repo_falls_to_configured_default(self, tmp_path):\n        \"\"\"cwd is a plain dir, configured default exists -> DEFAULT via step 5.\"\"\"\n        comfy_dir = str(tmp_path / \"ComfyUI\")\n        self._create_comfy_repo(comfy_dir)\n        plain_dir = str(tmp_path / \"plain\")\n        os.makedirs(plain_dir)\n\n        mgr = _make_manager(use_here=None)\n        _mock_config(mgr, default_workspace=comfy_dir)\n\n        with patch(\"comfy_cli.workspace_manager.os.getcwd\", return_value=plain_dir):\n            path, ws_type = mgr.get_workspace_path()\n\n        assert ws_type == WorkspaceType.DEFAULT\n        assert path == comfy_dir\n\n    def test_non_git_comfyui_detected_as_cwd(self, tmp_path):\n        \"\"\"Non-git ComfyUI (zip download) detected when cd'd into it.\"\"\"\n        comfy_dir = tmp_path / \"ComfyUI\"\n        comfy_dir.mkdir()\n        _create_comfyui_markers(comfy_dir)\n\n        mgr = _make_manager(use_here=None)\n        _mock_config(mgr, default_workspace=str(comfy_dir))\n\n        with patch(\"comfy_cli.workspace_manager.os.getcwd\", return_value=str(comfy_dir)):\n            path, ws_type = mgr.get_workspace_path()\n\n        assert ws_type == WorkspaceType.DEFAULT\n        assert path == str(comfy_dir)\n\n    def test_non_git_comfyui_as_configured_default(self, tmp_path):\n        \"\"\"Non-git ComfyUI install works as configured default (step 5).\"\"\"\n        comfy_dir = tmp_path / \"ComfyUI\"\n        comfy_dir.mkdir()\n        _create_comfyui_markers(comfy_dir)\n        plain_dir = tmp_path / \"other\"\n        plain_dir.mkdir()\n\n        mgr = _make_manager(use_here=None)\n        _mock_config(mgr, default_workspace=str(comfy_dir))\n\n        with patch(\"comfy_cli.workspace_manager.os.getcwd\", return_value=str(plain_dir)):\n            path, ws_type = mgr.get_workspace_path()\n\n        assert ws_type == WorkspaceType.DEFAULT\n        assert path == str(comfy_dir)\n\n    def test_non_git_comfyui_from_subdirectory(self, tmp_path):\n        \"\"\"Non-git ComfyUI detected when cd'd into custom_nodes/ subdirectory.\"\"\"\n        comfy_dir = tmp_path / \"ComfyUI\"\n        comfy_dir.mkdir()\n        _create_comfyui_markers(comfy_dir)\n        subdir = comfy_dir / \"custom_nodes\" / \"MyNode\"\n        subdir.mkdir(parents=True)\n\n        mgr = _make_manager(use_here=None)\n        _mock_config(mgr, default_workspace=str(comfy_dir))\n\n        with patch(\"comfy_cli.workspace_manager.os.getcwd\", return_value=str(subdir)):\n            path, ws_type = mgr.get_workspace_path()\n\n        assert ws_type == WorkspaceType.DEFAULT\n        assert path == str(comfy_dir)\n\n    def test_fork_repo_detected_as_comfyui(self, tmp_path):\n        \"\"\"ComfyUI fork (non-standard remote) detected via marker fallback.\"\"\"\n        import git as gitmodule\n\n        comfy_dir = tmp_path / \"ComfyUI\"\n        repo = gitmodule.Repo.init(str(comfy_dir))\n        repo.create_remote(\"origin\", \"https://github.com/someone/ComfyUI-fork\")\n        (comfy_dir / \"README.md\").write_text(\"fork\")\n        repo.index.add([\"README.md\"])\n        repo.index.commit(\"init\")\n\n        _create_comfyui_markers(comfy_dir)\n\n        mgr = _make_manager(use_here=None)\n        _mock_config(mgr, default_workspace=str(comfy_dir))\n\n        with patch(\"comfy_cli.workspace_manager.os.getcwd\", return_value=str(comfy_dir)):\n            path, ws_type = mgr.get_workspace_path()\n\n        assert ws_type == WorkspaceType.DEFAULT\n        assert path == str(comfy_dir)\n"
  },
  {
    "path": "tests/e2e/test_e2e.py",
    "content": "import os\nimport subprocess\nfrom datetime import datetime\nfrom textwrap import dedent\n\nimport pytest\n\nfrom comfy_cli.resolve_python import resolve_workspace_python\n\n\ndef e2e_test(func):\n    return pytest.mark.skipif(\n        os.getenv(\"TEST_E2E\", \"false\") != \"true\",\n        reason=\"Test e2e is not explicitly enabled\",\n    )(func)\n\n\ndef exec(cmd: str, **kwargs) -> subprocess.CompletedProcess[str]:\n    cmd = dedent(cmd).strip()\n    print(f\"cmd: {cmd}\")\n\n    proc = subprocess.run(\n        args=cmd,\n        capture_output=True,\n        text=True,\n        shell=True,\n        encoding=\"utf-8\",\n        check=False,\n        **kwargs,\n    )\n    print(proc.stdout, proc.stderr)\n    return proc\n\n\n@pytest.fixture(scope=\"module\")\ndef workspace():\n    ws = os.path.join(os.getcwd(), f\"comfy-{datetime.now().timestamp()}\")\n    install_flags = os.getenv(\"TEST_E2E_COMFY_INSTALL_FLAGS\", \"--cpu\")\n    comfy_url = os.getenv(\"TEST_E2E_COMFY_URL\", \"\")\n    url_flag = f\"--url {comfy_url}\" if comfy_url else \"\"\n    proc = exec(\n        f\"\"\"\n            comfy --skip-prompt --workspace {ws} install {url_flag} {install_flags}\n            comfy --skip-prompt set-default {ws}\n            comfy --skip-prompt --no-enable-telemetry env\n        \"\"\"\n    )\n    assert proc.returncode == 0\n\n    # Populate Manager cache before any node operations (blocking fetch).\n    proc = exec(f\"comfy --workspace {ws} node update-cache\")\n    assert proc.returncode == 0, f\"update-cache failed:\\n{proc.stderr}\"\n\n    proc = exec(\n        f\"\"\"\n        comfy --workspace {ws} launch --background -- {os.getenv(\"TEST_E2E_COMFY_LAUNCH_FLAGS_EXTRA\", \"--cpu\")}\n        \"\"\"\n    )\n    assert proc.returncode == 0\n\n    yield ws\n\n    proc = exec(\n        f\"\"\"\n        comfy --workspace {ws} stop\n        \"\"\"\n    )\n    assert proc.returncode == 0\n\n\n@pytest.fixture()\ndef comfy_cli(workspace):\n    exec(\"comfy --skip-prompt --no-enable-telemetry env\")\n    return f\"comfy --workspace {workspace}\"\n\n\n@e2e_test\ndef test_model(comfy_cli):\n    url = \"https://huggingface.co/guoyww/animatediff/resolve/cd71ae134a27ec6008b968d6419952b0c0494cf2/mm_sd_v14.ckpt?download=true\"\n    path = os.path.join(\"models\", \"animatediff_models\")\n    proc = exec(\n        f\"\"\"\n            {comfy_cli} model download --url {url} --relative-path {path} --filename animatediff_models\n        \"\"\"\n    )\n    assert proc.returncode == 0\n\n    proc = exec(\n        f\"\"\"\n            {comfy_cli} model list --relative-path {path}\n        \"\"\"\n    )\n    assert proc.returncode == 0\n    assert \"animatediff_models\" in proc.stdout\n\n    proc = exec(\n        f\"\"\"\n            {comfy_cli} model remove --relative-path {path} --model-names animatediff_models --confirm\n        \"\"\"\n    )\n    assert proc.returncode == 0\n\n\n@e2e_test\ndef test_node(comfy_cli, workspace):\n    node = \"comfyui-animatediff-evolved\"\n\n    # Use --exit-on-fail so the CLI returns non-zero on git clone failure\n    # instead of silently succeeding. Retry to handle transient network\n    # errors (GitHub rate-limiting git clones on Actions runners).\n    for attempt in range(3):\n        proc = exec(\n            f\"\"\"\n                {comfy_cli} node install --exit-on-fail {node}\n            \"\"\"\n        )\n        if proc.returncode == 0:\n            break\n    assert proc.returncode == 0, f\"node install failed after 3 attempts:\\n{proc.stderr}\"\n\n    for attempt in range(3):\n        proc = exec(\n            f\"\"\"\n                {comfy_cli} node reinstall {node}\n            \"\"\"\n        )\n        if proc.returncode == 0:\n            break\n    assert proc.returncode == 0, f\"node reinstall failed after 3 attempts:\\n{proc.stderr}\"\n\n    proc = exec(\n        f\"\"\"\n            {comfy_cli} node show all\n        \"\"\"\n    )\n    assert proc.returncode == 0\n    # cm-cli may display the repo name (ComfyUI-AnimateDiff-Evolved) rather\n    # than the registry id (comfyui-animatediff-evolved), so compare lowercase.\n    assert node.lower() in proc.stdout.lower()\n\n    proc = exec(\n        f\"\"\"\n            {comfy_cli} node update {node}\n        \"\"\"\n    )\n    assert proc.returncode == 0\n\n    proc = exec(\n        f\"\"\"\n            {comfy_cli} node disable {node}\n        \"\"\"\n    )\n    assert proc.returncode == 0\n\n    proc = exec(\n        f\"\"\"\n            {comfy_cli} node enable {node}\n        \"\"\"\n    )\n    assert proc.returncode == 0\n\n    pubID = \"comfytest123\"\n    pubToken = \"6075cf7b-47e7-4c58-a3de-38f59a9bcc22\"\n    proc = exec(\n        f\"\"\"\n            sed 's/PublisherId = \".*\"/PublisherId = \"{pubID}\"/g' pyproject.toml\n            {comfy_cli} node publish --token {pubToken}\n        \"\"\",\n        env={\"ENVIRONMENT\": \"stage\"},\n        cwd=os.path.join(workspace, \"custom_nodes\", node),\n    )\n\n\n@e2e_test\ndef test_manager_installed(comfy_cli, workspace):\n    \"\"\"Verify ComfyUI-Manager was installed via manager_requirements.txt.\"\"\"\n    proc = exec(\n        f\"\"\"\n            {comfy_cli} node show all\n        \"\"\"\n    )\n    assert proc.returncode == 0, f\"node show all failed: {proc.stderr}\"\n\n    # Check cm_cli is importable (Manager v4 installed as pip package)\n    ws_python = resolve_workspace_python(workspace)\n    proc = exec(\n        f\"\"\"\n            {ws_python} -c \"import cm_cli; print('cm_cli OK')\"\n        \"\"\"\n    )\n    assert proc.returncode == 0, f\"cm_cli import failed: {proc.stderr}\"\n    assert \"cm_cli OK\" in proc.stdout\n\n\n@e2e_test\ndef test_node_uv_compile(comfy_cli):\n    \"\"\"Test --uv-compile flag for node install (requires Manager v4.1+).\"\"\"\n    node = \"comfyui-impact-pack\"\n    proc = exec(\n        f\"\"\"\n            {comfy_cli} node install --uv-compile {node}\n        \"\"\"\n    )\n    assert proc.returncode == 0\n\n    # Standalone uv-sync command\n    proc = exec(\n        f\"\"\"\n            {comfy_cli} node uv-sync\n        \"\"\"\n    )\n    assert proc.returncode == 0\n\n\n@e2e_test\ndef test_uv_compile_default_config(comfy_cli):\n    \"\"\"Test comfy manager uv-compile-default config command.\"\"\"\n    proc = exec(\n        f\"\"\"\n            {comfy_cli} manager uv-compile-default true\n        \"\"\"\n    )\n    assert proc.returncode == 0\n    assert \"enabled\" in proc.stdout.lower()\n\n    # Verify it shows in env\n    proc = exec(\n        \"\"\"\n            comfy --skip-prompt --no-enable-telemetry env\n        \"\"\"\n    )\n    assert proc.returncode == 0\n    assert \"UV Compile Default\" in proc.stdout\n    assert \"Enabled\" in proc.stdout\n\n    # Disable it back\n    proc = exec(\n        f\"\"\"\n            {comfy_cli} manager uv-compile-default false\n        \"\"\"\n    )\n    assert proc.returncode == 0\n    assert \"disabled\" in proc.stdout.lower()\n\n\n@e2e_test\ndef test_install_version_latest_no_github_api(tmp_path):\n    \"\"\"Regression test for issue #440.\n\n    Runs `comfy install --version latest` end-to-end and verifies:\n    - The command succeeds without a GitHub token in the environment.\n    - The resulting clone has a stable semver tag (v*) checked out — proving\n      the local-tag resolver actually picked something instead of failing\n      over to the rate-limited API.\n\n    Slow pip steps are skipped to keep this targeted at the version-resolution\n    path; the real protection is exercising the actual CLI command, so any\n    future refactor that puts `releases/latest` API calls back on this path\n    fails CI loudly.\n    \"\"\"\n    # Use tmp_path (auto-cleaned) so the clone doesn't leak into cwd.\n    ws = str(tmp_path / \"comfy-latest\")\n    env = {**os.environ}\n    env.pop(\"GITHUB_TOKEN\", None)  # mimic the user from the bug report\n\n    # Keep the command on a single line: bash uses `\\` for line continuation but\n    # Windows cmd.exe uses `^` and treats a stray `\\` as a positional argument.\n    proc = exec(\n        f\"comfy --skip-prompt --workspace {ws} install --cpu --version latest \"\n        \"--skip-manager --skip-torch-or-directml --skip-requirement\",\n        env=env,\n    )\n    assert proc.returncode == 0, f\"install --version latest failed:\\n{proc.stderr}\"\n\n    # The actual property under test: we did NOT fall back to the GitHub API.\n    # Both fallback messages from checkout_stable_comfyui mention \"GitHub API\"\n    # (\"querying GitHub API\" and \"trying GitHub API as a last resort\"); catch\n    # either via the shared substring so the assertion stays tight even if the\n    # exact wording changes.\n    combined = proc.stdout + proc.stderr\n    assert \"GitHub API\" not in combined, (\n        f\"Install fell back to the GitHub API — local-tag resolution must have failed.\\nOutput:\\n{combined}\"\n    )\n\n    # `--workspace ws` clones directly into ws (matches the existing fixture's behavior).\n    assert os.path.isdir(os.path.join(ws, \".git\")), f\"no git repo at {ws}\"\n\n    head = subprocess.run(\n        [\"git\", \"-C\", ws, \"describe\", \"--tags\", \"--exact-match\", \"HEAD\"],\n        capture_output=True,\n        text=True,\n        check=False,\n    )\n    assert head.returncode == 0, (\n        f\"HEAD is not on a tag — local tag resolution must have silently failed. stderr: {head.stderr}\"\n    )\n    tag = head.stdout.strip()\n    assert tag.startswith(\"v\") and tag.count(\".\") == 2, f\"Expected a v<major>.<minor>.<patch> stable tag, got: {tag!r}\"\n    # Pre-releases (v*-rc1, v*-beta) must be skipped to mirror GitHub's releases/latest.\n    assert \"-\" not in tag, f\"Resolver picked a pre-release tag: {tag!r}\"\n\n\n@e2e_test\ndef test_run(comfy_cli):\n    url = \"https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true\"\n    path = os.path.join(\"models\", \"checkpoints\")\n    name = \"v1-5-pruned-emaonly.safetensors\"\n    proc = exec(\n        f\"\"\"\n            {comfy_cli} model download --url {url} --relative-path {path} --filename {name}\n        \"\"\"\n    )\n    assert proc.returncode == 0\n\n    workflow = os.path.join(os.path.dirname(os.path.realpath(__file__)), \"workflow.json\")\n    proc = exec(\n        f\"\"\"\n        {comfy_cli} run --workflow {workflow} --wait --timeout 180\n        \"\"\"\n    )\n    assert proc.returncode == 0\n"
  },
  {
    "path": "tests/e2e/test_e2e_uv_compile.py",
    "content": "\"\"\"E2E tests for comfy-cli uv-compile support (requires Manager v4.1+).\n\nTests the full stack: comfy node → execute_cm_cli → cm_cli subprocess.\nUses ltdrdata's dedicated test packs (nodepack-test1-do-not-install,\nnodepack-test2-do-not-install) which intentionally conflict on ansible\nversions and contain no executable code.\n\nSupply-chain safety policy:\n    Only node packs from verified, controllable authors (ltdrdata,\n    comfyanonymous) are used. Adding packs from unverified sources\n    is prohibited.\n\nUsage:\n    TEST_E2E=true \\\\\n    TEST_E2E_COMFY_URL=\"https://github.com/ltdrdata/ComfyUI.git@dr-bump-manager\" \\\\\n    pytest tests/e2e/test_e2e_uv_compile.py -v\n\"\"\"\n\nimport os\nimport shutil\nimport subprocess\nimport sys\nfrom datetime import datetime\nfrom textwrap import dedent\n\nimport pytest\n\n# Real node packs for normal installation testing\nPACK_IMPACT = \"comfyui-impact-pack\"\nPACK_INSPIRE = \"comfyui-inspire-pack\"\n\n# Test node packs from ltdrdata — intentionally conflict on ansible versions\nREPO_TEST1 = \"https://github.com/ltdrdata/nodepack-test1-do-not-install\"\nREPO_TEST2 = \"https://github.com/ltdrdata/nodepack-test2-do-not-install\"\nPACK_TEST1 = \"nodepack-test1-do-not-install\"\nPACK_TEST2 = \"nodepack-test2-do-not-install\"\n\n\ndef _e2e_enabled():\n    return os.getenv(\"TEST_E2E\", \"false\") == \"true\"\n\n\npytestmark = [\n    pytest.mark.skipif(not _e2e_enabled(), reason=\"TEST_E2E not enabled\"),\n]\n\n\ndef exec(cmd: str, timeout: int = 600, **kwargs) -> subprocess.CompletedProcess[str]:\n    cmd = dedent(cmd).strip()\n    print(f\"cmd: {cmd}\")\n    try:\n        proc = subprocess.run(\n            args=cmd,\n            capture_output=True,\n            text=True,\n            shell=True,\n            encoding=\"utf-8\",\n            check=False,\n            timeout=timeout,\n            **kwargs,\n        )\n    except subprocess.TimeoutExpired as e:\n        print(f\"[exec] TIMEOUT after {timeout}s: {cmd}\", flush=True)\n        # Return a synthetic failed result so tests get a clear failure message\n        return subprocess.CompletedProcess(\n            args=cmd,\n            returncode=124,\n            stdout=e.stdout or \"\",\n            stderr=e.stderr or f\"Timed out after {timeout}s\",\n        )\n    print(proc.stdout, proc.stderr)\n    return proc\n\n\ndef _rmtree_retry(path, retries=5, delay=2.0):\n    \"\"\"Remove directory with retries for Windows file lock delays.\n\n    On Windows, .git/objects/pack/* files may be briefly locked after\n    git clone exits. Retries with read-only file handling.\n    \"\"\"\n    import stat\n    import time\n\n    def _on_rm_error(func, fpath, _exc_info):\n        \"\"\"Handle read-only files on Windows (e.g. .git/objects/pack/*.idx).\"\"\"\n        try:\n            os.chmod(fpath, stat.S_IWRITE)\n            func(fpath)\n        except OSError:\n            pass\n\n    for attempt in range(retries):\n        try:\n            shutil.rmtree(path, onerror=_on_rm_error)\n            return\n        except (PermissionError, OSError):\n            if attempt < retries - 1:\n                time.sleep(delay)\n    shutil.rmtree(path, ignore_errors=True)\n\n\n# ---------------------------------------------------------------------------\n# Fixtures\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture(scope=\"module\")\ndef workspace():\n    \"\"\"Install ComfyUI (with Manager v4) and launch in background.\"\"\"\n    ws = os.path.join(os.getcwd(), f\"comfy-uv-{datetime.now().timestamp()}\")\n    install_flags = os.getenv(\"TEST_E2E_COMFY_INSTALL_FLAGS\", \"--cpu\")\n    comfy_url = os.getenv(\"TEST_E2E_COMFY_URL\", \"\")\n    url_flag = f\"--url {comfy_url}\" if comfy_url else \"\"\n\n    proc = exec(\n        f\"\"\"\n            comfy --skip-prompt --workspace {ws} install {url_flag} {install_flags}\n            comfy --skip-prompt set-default {ws}\n            comfy --skip-prompt --no-enable-telemetry env\n        \"\"\"\n    )\n    assert proc.returncode == 0\n\n    # Override Manager if MANAGER_OVERRIDE is set (skip PyPI/Core PR cycle).\n    # Accepts:  \"4.1b7\" (PyPI)  or  \"Comfy-Org/ComfyUI-Manager@branch\" (git clone + uv)\n    venv_dir = os.path.join(ws, \".venv\")\n    if sys.platform == \"win32\":\n        venv_python = os.path.join(venv_dir, \"Scripts\", \"python.exe\")\n    else:\n        venv_python = os.path.join(venv_dir, \"bin\", \"python\")\n\n    manager_override = os.getenv(\"MANAGER_OVERRIDE\", \"\")\n    if manager_override:\n        if \"@\" in manager_override and \"/\" in manager_override:\n            # Branch install: \"Comfy-Org/ComfyUI-Manager@fix-branch\"\n            # Uses uv (not pip) because Manager repo has flat-layout incompatible with setuptools.\n            repo_spec, branch = manager_override.rsplit(\"@\", 1)\n            clone_dir = os.path.join(ws, \"_manager_override\")\n            if os.path.isdir(clone_dir):\n                shutil.rmtree(clone_dir)\n            proc = exec(f\"git clone --branch {branch} --depth 1 https://github.com/{repo_spec}.git {clone_dir}\")\n            assert proc.returncode == 0, f\"Manager clone failed:\\n{proc.stderr}\"\n            proc = exec(f\"uv pip install {clone_dir} --reinstall-package comfyui-manager --python {venv_dir}\")\n            assert proc.returncode == 0, f\"Manager override install failed:\\n{proc.stderr}\"\n        else:\n            # PyPI version: \"4.1b7\"\n            proc = exec(\n                f\"{venv_python} -m pip install comfyui-manager=={manager_override} --pre --force-reinstall --no-deps\"\n            )\n            assert proc.returncode == 0, f\"Manager override failed:\\n{proc.stderr}\"\n\n    # Populate Manager cache before any node operations (blocking fetch).\n    proc = exec(f\"comfy --workspace {ws} node update-cache\")\n    assert proc.returncode == 0, f\"update-cache failed:\\n{proc.stderr}\"\n\n    # NOTE: No 'comfy launch --background' here. These tests only exercise\n    # cm_cli commands (node install/reinstall/update/fix/uv-sync) and don't\n    # need a running ComfyUI server. Launching ComfyUI in background causes\n    # Windows file lock issues: ComfyUI scans custom_nodes/, holds handles\n    # on .git/objects/pack/*.idx, and prevents cleanup between tests.\n\n    yield ws\n\n\n@pytest.fixture()\ndef comfy_cli(workspace):\n    return f\"comfy --workspace {workspace}\"\n\n\n@pytest.fixture(autouse=True)\ndef _clean_test_packs(workspace):\n    \"\"\"Remove test node packs before and after each test.\"\"\"\n    custom_nodes = os.path.join(workspace, \"custom_nodes\")\n\n    def _remove(name):\n        path = os.path.join(custom_nodes, name)\n        if os.path.islink(path):\n            os.unlink(path)\n        elif os.path.isdir(path):\n            _rmtree_retry(path)\n\n    _remove(PACK_TEST1)\n    _remove(PACK_TEST2)\n    yield\n    _remove(PACK_TEST1)\n    _remove(PACK_TEST2)\n\n\n# ---------------------------------------------------------------------------\n# Normal installation with real packs\n# ---------------------------------------------------------------------------\n\n\ndef test_real_packs_sequential_no_conflict(comfy_cli):\n    \"\"\"Sequential install of two real packs with --uv-compile — no conflicts.\"\"\"\n    proc = exec(f\"{comfy_cli} node install --uv-compile {PACK_IMPACT}\")\n    combined = proc.stdout + proc.stderr\n\n    assert proc.returncode == 0\n    assert \"Resolving dependencies for\" in combined\n\n    proc = exec(f\"{comfy_cli} node install --uv-compile {PACK_INSPIRE}\")\n    combined = proc.stdout + proc.stderr\n\n    assert proc.returncode == 0\n    assert \"Resolving dependencies for\" in combined\n    assert \"Conflicting packages\" not in combined\n\n\ndef test_real_packs_simultaneous_no_conflict(comfy_cli):\n    \"\"\"Simultaneous install of two real packs with --uv-compile — no conflicts.\"\"\"\n    proc = exec(f\"{comfy_cli} node install --uv-compile {PACK_IMPACT} {PACK_INSPIRE}\")\n    combined = proc.stdout + proc.stderr\n\n    assert proc.returncode == 0\n    assert \"Resolving dependencies for\" in combined\n    assert \"Conflicting packages\" not in combined\n\n\n# ---------------------------------------------------------------------------\n# Progressive conflict (real packs + conflict packs)\n# ---------------------------------------------------------------------------\n\n\ndef test_progressive_conflict(comfy_cli):\n    \"\"\"Real packs installed → +conflict-pack-1 OK → +conflict-pack-2 CONFLICT.\"\"\"\n    # Step 1: Install real packs — no conflict\n    proc = exec(f\"{comfy_cli} node install --uv-compile {PACK_IMPACT} {PACK_INSPIRE}\")\n    combined = proc.stdout + proc.stderr\n    assert proc.returncode == 0\n    assert \"Conflicting packages\" not in combined\n\n    # Step 2: Add first conflict test pack — still no conflict\n    proc = exec(f\"{comfy_cli} node install --uv-compile {REPO_TEST1}\")\n    combined = proc.stdout + proc.stderr\n    assert proc.returncode == 0\n    assert \"Conflicting packages\" not in combined\n\n    # Step 3: Add second conflict test pack — conflict between test packs\n    proc = exec(f\"{comfy_cli} node install --uv-compile {REPO_TEST2}\")\n    combined = proc.stdout + proc.stderr\n    assert \"Conflicting packages (by node pack):\" in combined\n    assert PACK_TEST1 in combined\n    assert PACK_TEST2 in combined\n\n\n# ---------------------------------------------------------------------------\n# Reinstall / Update / Fix with --uv-compile\n# ---------------------------------------------------------------------------\n\n\ndef test_node_reinstall_uv_compile(comfy_cli):\n    \"\"\"Reinstall with --uv-compile → resolution runs.\"\"\"\n    setup = exec(f\"{comfy_cli} node install {REPO_TEST1}\")\n    assert setup.returncode == 0, f\"Setup install failed: {setup.stderr}\"\n\n    proc = exec(f\"{comfy_cli} node reinstall --uv-compile {REPO_TEST1}\")\n    combined = proc.stdout + proc.stderr\n\n    assert proc.returncode == 0, f\"reinstall failed: {proc.stderr}\"\n    assert \"Resolving dependencies for\" in combined\n\n\ndef test_node_update_uv_compile(comfy_cli):\n    \"\"\"Update with --uv-compile → resolution runs.\"\"\"\n    setup = exec(f\"{comfy_cli} node install {REPO_TEST1}\")\n    assert setup.returncode == 0, f\"Setup install failed: {setup.stderr}\"\n\n    proc = exec(f\"{comfy_cli} node update --uv-compile {REPO_TEST1}\")\n    combined = proc.stdout + proc.stderr\n\n    assert proc.returncode == 0, f\"update failed: {proc.stderr}\"\n    assert \"Resolving dependencies for\" in combined\n\n\ndef test_node_fix_uv_compile(comfy_cli):\n    \"\"\"Fix with --uv-compile → resolution runs.\"\"\"\n    setup = exec(f\"{comfy_cli} node install {REPO_TEST1}\")\n    assert setup.returncode == 0, f\"Setup install failed: {setup.stderr}\"\n\n    proc = exec(f\"{comfy_cli} node fix --uv-compile {REPO_TEST1}\")\n    combined = proc.stdout + proc.stderr\n\n    assert proc.returncode == 0, f\"fix failed: {proc.stderr}\"\n    assert \"Resolving dependencies for\" in combined\n\n\ndef test_node_restore_deps_uv_compile(comfy_cli):\n    \"\"\"restore-dependencies --uv-compile → resolution runs.\"\"\"\n    setup = exec(f\"{comfy_cli} node install {REPO_TEST1}\")\n    assert setup.returncode == 0, f\"Setup install failed: {setup.stderr}\"\n\n    proc = exec(f\"{comfy_cli} node restore-dependencies --uv-compile\")\n    combined = proc.stdout + proc.stderr\n\n    assert proc.returncode == 0, f\"restore-dependencies failed: {proc.stderr}\"\n    assert \"Resolving dependencies for\" in combined\n\n\n# ---------------------------------------------------------------------------\n# Standalone uv-sync\n# ---------------------------------------------------------------------------\n\n\ndef test_node_uv_sync_standalone(comfy_cli):\n    \"\"\"Standalone comfy node uv-sync with installed pack.\"\"\"\n    setup = exec(f\"{comfy_cli} node install {REPO_TEST1}\")\n    assert setup.returncode == 0, f\"Setup install failed: {setup.stderr}\"\n\n    proc = exec(f\"{comfy_cli} node uv-sync\")\n    combined = proc.stdout + proc.stderr\n\n    assert proc.returncode == 0\n    assert \"Resolving dependencies for\" in combined\n\n\ndef test_node_uv_sync_standalone_conflict(comfy_cli):\n    \"\"\"Standalone uv-sync with conflicting packs → conflict attribution.\"\"\"\n    setup1 = exec(f\"{comfy_cli} node install {REPO_TEST1}\")\n    assert setup1.returncode == 0, f\"Setup install test1 failed: {setup1.stderr}\"\n    setup2 = exec(f\"{comfy_cli} node install {REPO_TEST2}\")\n    assert setup2.returncode == 0, f\"Setup install test2 failed: {setup2.stderr}\"\n\n    proc = exec(f\"{comfy_cli} node uv-sync\")\n    combined = proc.stdout + proc.stderr\n\n    assert \"Conflicting packages (by node pack):\" in combined\n    assert PACK_TEST1 in combined\n    assert PACK_TEST2 in combined\n\n\n# ---------------------------------------------------------------------------\n# Config default\n# ---------------------------------------------------------------------------\n\n\ndef test_uv_compile_config_default(comfy_cli):\n    \"\"\"Config default true → install without flag triggers resolution.\"\"\"\n    proc = exec(f\"{comfy_cli} manager uv-compile-default true\")\n    assert proc.returncode == 0\n\n    try:\n        proc = exec(f\"{comfy_cli} node install {REPO_TEST1}\")\n        combined = proc.stdout + proc.stderr\n\n        assert proc.returncode == 0, f\"install failed: {proc.stderr}\"\n        assert \"Resolving dependencies for\" in combined\n    finally:\n        exec(f\"{comfy_cli} manager uv-compile-default false\")\n\n\ndef test_no_uv_compile_overrides_config(comfy_cli):\n    \"\"\"--no-uv-compile overrides config default.\"\"\"\n    proc = exec(f\"{comfy_cli} manager uv-compile-default true\")\n    assert proc.returncode == 0\n\n    try:\n        proc = exec(f\"{comfy_cli} node install --no-uv-compile {REPO_TEST1}\")\n        combined = proc.stdout + proc.stderr\n\n        assert proc.returncode == 0, f\"install failed: {proc.stderr}\"\n        assert \"Resolving dependencies for\" not in combined\n    finally:\n        exec(f\"{comfy_cli} manager uv-compile-default false\")\n\n\n# ---------------------------------------------------------------------------\n# Mutual exclusivity\n# ---------------------------------------------------------------------------\n\n\ndef test_uv_compile_mutual_exclusivity(comfy_cli):\n    \"\"\"--uv-compile cannot be used with --fast-deps or --no-deps.\"\"\"\n    # --uv-compile + --fast-deps\n    proc = exec(f\"{comfy_cli} node install --uv-compile --fast-deps {REPO_TEST1}\")\n    assert proc.returncode != 0\n    assert \"Cannot use\" in (proc.stdout + proc.stderr)\n\n    # --uv-compile + --no-deps\n    proc = exec(f\"{comfy_cli} node install --uv-compile --no-deps {REPO_TEST1}\")\n    assert proc.returncode != 0\n    assert \"Cannot use\" in (proc.stdout + proc.stderr)\n"
  },
  {
    "path": "tests/e2e/workflow.json",
    "content": "{\n  \"3\": {\n    \"inputs\": {\n      \"seed\": 156680208700286,\n      \"steps\": 20,\n      \"cfg\": 8,\n      \"sampler_name\": \"euler\",\n      \"scheduler\": \"normal\",\n      \"denoise\": 1,\n      \"model\": [\n        \"4\",\n        0\n      ],\n      \"positive\": [\n        \"6\",\n        0\n      ],\n      \"negative\": [\n        \"7\",\n        0\n      ],\n      \"latent_image\": [\n        \"5\",\n        0\n      ]\n    },\n    \"class_type\": \"KSampler\",\n    \"_meta\": {\n      \"title\": \"KSampler\"\n    }\n  },\n  \"4\": {\n    \"inputs\": {\n      \"ckpt_name\": \"v1-5-pruned-emaonly.safetensors\"\n    },\n    \"class_type\": \"CheckpointLoaderSimple\",\n    \"_meta\": {\n      \"title\": \"Load Checkpoint\"\n    }\n  },\n  \"5\": {\n    \"inputs\": {\n      \"width\": 512,\n      \"height\": 512,\n      \"batch_size\": 1\n    },\n    \"class_type\": \"EmptyLatentImage\",\n    \"_meta\": {\n      \"title\": \"Empty Latent Image\"\n    }\n  },\n  \"6\": {\n    \"inputs\": {\n      \"text\": \"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,\",\n      \"clip\": [\n        \"4\",\n        1\n      ]\n    },\n    \"class_type\": \"CLIPTextEncode\",\n    \"_meta\": {\n      \"title\": \"CLIP Text Encode (Prompt)\"\n    }\n  },\n  \"7\": {\n    \"inputs\": {\n      \"text\": \"text, watermark\",\n      \"clip\": [\n        \"4\",\n        1\n      ]\n    },\n    \"class_type\": \"CLIPTextEncode\",\n    \"_meta\": {\n      \"title\": \"CLIP Text Encode (Prompt)\"\n    }\n  },\n  \"8\": {\n    \"inputs\": {\n      \"samples\": [\n        \"3\",\n        0\n      ],\n      \"vae\": [\n        \"4\",\n        2\n      ]\n    },\n    \"class_type\": \"VAEDecode\",\n    \"_meta\": {\n      \"title\": \"VAE Decode\"\n    }\n  },\n  \"9\": {\n    \"inputs\": {\n      \"filename_prefix\": \"ComfyUI\",\n      \"images\": [\n        \"8\",\n        0\n      ]\n    },\n    \"class_type\": \"SaveImage\",\n    \"_meta\": {\n      \"title\": \"Save Image\"\n    }\n  }\n}"
  },
  {
    "path": "tests/test_file_utils_network.py",
    "content": "import json\nimport pathlib\nfrom unittest.mock import Mock, patch\n\nimport httpx\nimport pytest\nimport requests\n\nfrom comfy_cli.file_utils import (\n    DownloadException,\n    _cleanup_partial,\n    _friendly_network_error,\n    _TransientHTTPStatusError,\n    check_unauthorized,\n    download_file,\n    extract_package_as_zip,\n    guess_status_code_reason,\n    upload_file_to_signed_url,\n)\n\n\ndef test_guess_status_code_reason_401_with_json():\n    message = json.dumps({\"message\": \"API token required\"}).encode()\n    result = guess_status_code_reason(401, message)\n    assert \"API token required\" in result\n    assert \"Unauthorized download (401)\" in result\n\n\ndef test_guess_status_code_reason_401_without_json():\n    result = guess_status_code_reason(401, \"not json\")\n    assert \"Unauthorized download (401)\" in result\n    assert \"manually log into a browser\" in result\n\n\ndef test_guess_status_code_reason_403():\n    result = guess_status_code_reason(403, \"\")\n    assert \"Forbidden url (403)\" in result\n\n\ndef test_guess_status_code_reason_404():\n    result = guess_status_code_reason(404, \"\")\n    assert \"not found on server (404)\" in result\n\n\ndef test_guess_status_code_reason_unknown():\n    result = guess_status_code_reason(500, \"\")\n    assert \"Unknown error occurred (status code: 500)\" in result\n\n\n@patch(\"requests.get\")\ndef test_check_unauthorized_true(mock_get):\n    mock_response = Mock()\n    mock_response.status_code = 401\n    mock_get.return_value = mock_response\n\n    assert check_unauthorized(\"http://example.com\") is True\n\n\n@patch(\"requests.get\")\ndef test_check_unauthorized_false(mock_get):\n    mock_response = Mock()\n    mock_response.status_code = 200\n    mock_get.return_value = mock_response\n\n    assert check_unauthorized(\"http://example.com\") is False\n\n\n@patch(\"requests.get\")\ndef test_check_unauthorized_exception(mock_get):\n    mock_get.side_effect = requests.RequestException()\n\n    assert check_unauthorized(\"http://example.com\") is False\n\n\n@patch(\"httpx.stream\")\ndef test_download_file_success(mock_stream, tmp_path):\n    mock_response = Mock()\n    mock_response.status_code = 200\n    mock_response.headers = {\"Content-Length\": \"1024\"}\n    mock_response.iter_bytes.return_value = [b\"test data\"]\n    mock_response.__enter__ = Mock(return_value=mock_response)\n    mock_response.__exit__ = Mock(return_value=None)\n    mock_stream.return_value = mock_response\n\n    test_file = tmp_path / \"test.txt\"\n    download_file(\"http://example.com\", test_file)\n\n    assert test_file.exists()\n    assert test_file.read_bytes() == b\"test data\"\n\n\n@patch(\"httpx.stream\")\ndef test_download_file_success_without_content_length(mock_stream, tmp_path):\n    \"\"\"Download should succeed when Content-Length header is missing (e.g. chunked/gzip responses).\"\"\"\n    mock_response = Mock()\n    mock_response.status_code = 200\n    mock_response.headers = {}\n    mock_response.iter_bytes.return_value = [b\"chunk1\", b\"chunk2\"]\n    mock_response.__enter__ = Mock(return_value=mock_response)\n    mock_response.__exit__ = Mock(return_value=None)\n    mock_stream.return_value = mock_response\n\n    test_file = tmp_path / \"test.txt\"\n    download_file(\"http://example.com\", test_file)\n\n    assert test_file.exists()\n    assert test_file.read_bytes() == b\"chunk1chunk2\"\n\n\n@patch(\"httpx.stream\")\ndef test_download_file_failure(mock_stream):\n    mock_response = Mock()\n    mock_response.status_code = 404\n    mock_response.read.return_value = \"\"\n    mock_response.__enter__ = Mock(return_value=mock_response)\n    mock_response.__exit__ = Mock(return_value=None)\n    mock_stream.return_value = mock_response\n\n    with pytest.raises(DownloadException) as exc_info:\n        download_file(\"http://example.com\", pathlib.Path(\"test.txt\"))\n\n    assert \"Failed to download file\" in str(exc_info.value)\n\n\n@patch(\"requests.put\")\ndef test_upload_file_success(mock_put, tmp_path):\n    test_file = tmp_path / \"test.zip\"\n    test_file.write_bytes(b\"test data\")\n\n    mock_response = Mock()\n    mock_response.status_code = 200\n    mock_put.return_value = mock_response\n\n    upload_file_to_signed_url(\"http://example.com\", str(test_file))\n\n    mock_put.assert_called_once()\n\n\n@patch(\"requests.put\")\ndef test_upload_file_failure(mock_put, tmp_path):\n    test_file = tmp_path / \"test.zip\"\n    test_file.write_bytes(b\"test data\")\n\n    mock_response = Mock()\n    mock_response.status_code = 500\n    mock_response.text = \"Server error\"\n    mock_put.return_value = mock_response\n\n    with pytest.raises(Exception) as exc_info:\n        upload_file_to_signed_url(\"http://example.com\", str(test_file))\n\n    assert \"Upload failed\" in str(exc_info.value)\n\n\ndef test_extract_package_as_zip(tmp_path):\n    # Create a test zip file\n    import zipfile\n\n    zip_path = tmp_path / \"test.zip\"\n    extract_path = tmp_path / \"extracted\"\n\n    with zipfile.ZipFile(zip_path, \"w\") as test_zip:\n        test_zip.writestr(\"test.txt\", \"test content\")\n\n    extract_package_as_zip(zip_path, extract_path)\n\n    assert (extract_path / \"test.txt\").exists()\n    assert (extract_path / \"test.txt\").read_text() == \"test content\"\n\n\ndef _make_ok_response(content=b\"data\", content_length=None):\n    \"\"\"Create a mock httpx response that succeeds.\"\"\"\n    mock = Mock()\n    mock.status_code = 200\n    mock.headers = {}\n    if content_length is not None:\n        mock.headers[\"Content-Length\"] = str(content_length)\n    mock.iter_bytes.return_value = [content]\n    mock.__enter__ = Mock(return_value=mock)\n    mock.__exit__ = Mock(return_value=None)\n    return mock\n\n\ndef _make_failing_iter(data=b\"partial\", exc=None):\n    \"\"\"Return a callable that creates a generator yielding *data* then raising *exc*.\"\"\"\n    if exc is None:\n        exc = httpx.ReadTimeout(\"read timed out\")\n\n    def factory():\n        yield data\n        raise exc\n\n    return factory\n\n\ndef _make_status_response(status_code, body=b\"\"):\n    \"\"\"Create a mock httpx response for a non-200 status.\"\"\"\n    mock = Mock()\n    mock.status_code = status_code\n    mock.read.return_value = body\n    mock.__enter__ = Mock(return_value=mock)\n    mock.__exit__ = Mock(return_value=None)\n    return mock\n\n\nclass TestCleanupPartial:\n    def test_removes_existing_file(self, tmp_path):\n        f = tmp_path / \"partial.bin\"\n        f.write_bytes(b\"partial\")\n        _cleanup_partial(f)\n        assert not f.exists()\n\n    def test_noop_when_file_missing(self, tmp_path):\n        f = tmp_path / \"nonexistent.bin\"\n        _cleanup_partial(f)  # should not raise\n        assert not f.exists()\n\n\nclass TestFriendlyNetworkError:\n    def test_read_timeout(self):\n        msg = _friendly_network_error(httpx.ReadTimeout(\"timed out\"))\n        assert \"read timeout\" in msg\n\n    def test_connect_timeout(self):\n        msg = _friendly_network_error(httpx.ConnectTimeout(\"timed out\"))\n        assert \"connect timeout\" in msg\n\n    def test_generic_timeout(self):\n        msg = _friendly_network_error(httpx.PoolTimeout(\"pool full\"))\n        assert \"PoolTimeout\" in msg\n\n    def test_network_error(self):\n        msg = _friendly_network_error(httpx.ReadError(\"connection reset\"))\n        assert \"ReadError\" in msg\n\n    def test_protocol_error(self):\n        msg = _friendly_network_error(httpx.RemoteProtocolError(\"peer closed\"))\n        assert \"protocol error\" in msg\n        assert \"RemoteProtocolError\" in msg\n\n    def test_proxy_error(self):\n        msg = _friendly_network_error(httpx.ProxyError(\"bad proxy\"))\n        assert \"proxy error\" in msg\n        assert \"ProxyError\" in msg\n\n    def test_other_exception(self):\n        msg = _friendly_network_error(RuntimeError(\"boom\"))\n        assert msg == \"boom\"\n\n    def test_transient_http_status_known_code_includes_phrase(self):\n        # HTTP 503 -> \"Service Unavailable\" (from stdlib http.HTTPStatus).\n        msg = _friendly_network_error(_TransientHTTPStatusError(503, \"some reason from body\"))\n        assert \"HTTP 503\" in msg\n        assert \"Service Unavailable\" in msg\n\n    def test_transient_http_status_500_includes_phrase(self):\n        msg = _friendly_network_error(_TransientHTTPStatusError(500, \"\"))\n        assert \"HTTP 500\" in msg\n        assert \"Internal Server Error\" in msg\n\n    def test_transient_http_status_unknown_code_falls_back(self):\n        # 599 is not a standard HTTPStatus; fall back to just the numeric code.\n        msg = _friendly_network_error(_TransientHTTPStatusError(599, \"weird\"))\n        assert \"HTTP 599\" in msg\n        # No crash, no stdlib phrase embedded (since there isn't one).\n\n    def test_invalid_url(self):\n        msg = _friendly_network_error(httpx.InvalidURL(\"Request URL is missing a scheme\"))\n        assert \"invalid URL\" in msg\n        assert \"missing a scheme\" in msg\n\n\nclass TestDownloadTimeout:\n    @patch(\"httpx.stream\")\n    def test_uses_generous_timeout(self, mock_stream, tmp_path):\n        \"\"\"httpx.stream is called with a 300s read timeout.\"\"\"\n        mock_stream.return_value = _make_ok_response()\n        download_file(\"http://example.com/f.bin\", tmp_path / \"f.bin\")\n\n        _, kwargs = mock_stream.call_args\n        timeout = kwargs[\"timeout\"]\n        assert isinstance(timeout, httpx.Timeout)\n        assert timeout.read == 300.0\n        assert timeout.connect == 10.0\n\n\nclass TestDownloadRetry:\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_succeeds_after_transient_timeout(self, mock_stream, mock_sleep, tmp_path):\n        \"\"\"Download retries on ReadTimeout and eventually succeeds.\"\"\"\n        mock_stream.side_effect = [\n            httpx.ReadTimeout(\"timeout\"),\n            _make_ok_response(content=b\"full data\"),\n        ]\n\n        dest = tmp_path / \"model.bin\"\n        download_file(\"http://example.com/model.bin\", dest)\n\n        assert dest.read_bytes() == b\"full data\"\n        assert mock_stream.call_count == 2\n        mock_sleep.assert_called_once_with(2)  # backoff: 2 * (0+1)\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_succeeds_after_network_error(self, mock_stream, mock_sleep, tmp_path):\n        \"\"\"Download retries on NetworkError (e.g. connection reset).\"\"\"\n        mock_stream.side_effect = [\n            httpx.ReadError(\"connection reset\"),\n            httpx.ConnectError(\"refused\"),\n            _make_ok_response(content=b\"ok\"),\n        ]\n\n        dest = tmp_path / \"model.bin\"\n        download_file(\"http://example.com/model.bin\", dest)\n\n        assert dest.read_bytes() == b\"ok\"\n        assert mock_stream.call_count == 3\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_succeeds_after_protocol_error(self, mock_stream, mock_sleep, tmp_path):\n        \"\"\"Download retries on RemoteProtocolError (e.g. peer closed connection mid-stream).\"\"\"\n        mock_stream.side_effect = [\n            httpx.RemoteProtocolError(\"peer closed connection\"),\n            _make_ok_response(content=b\"ok\"),\n        ]\n\n        dest = tmp_path / \"model.bin\"\n        download_file(\"http://example.com/model.bin\", dest)\n\n        assert dest.read_bytes() == b\"ok\"\n        assert mock_stream.call_count == 2\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_succeeds_after_proxy_error(self, mock_stream, mock_sleep, tmp_path):\n        \"\"\"Download retries on ProxyError.\"\"\"\n        mock_stream.side_effect = [\n            httpx.ProxyError(\"bad gateway\"),\n            _make_ok_response(content=b\"ok\"),\n        ]\n\n        dest = tmp_path / \"model.bin\"\n        download_file(\"http://example.com/model.bin\", dest)\n\n        assert dest.read_bytes() == b\"ok\"\n        assert mock_stream.call_count == 2\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_all_retries_exhausted_read_timeout(self, mock_stream, mock_sleep, tmp_path):\n        \"\"\"DownloadException after all retries fail with ReadTimeout.\"\"\"\n        mock_stream.side_effect = httpx.ReadTimeout(\"timeout\")\n\n        dest = tmp_path / \"model.bin\"\n        with pytest.raises(DownloadException, match=\"Download failed after 3 attempts\") as exc_info:\n            download_file(\"http://example.com/model.bin\", dest)\n\n        assert \"read timeout\" in str(exc_info.value)\n        assert \"try again\" in str(exc_info.value).lower()\n        assert mock_stream.call_count == 3\n        assert not dest.exists()\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_all_retries_exhausted_connect_error(self, mock_stream, mock_sleep, tmp_path):\n        \"\"\"DownloadException after all retries fail with ConnectError.\"\"\"\n        mock_stream.side_effect = httpx.ConnectError(\"refused\")\n\n        dest = tmp_path / \"model.bin\"\n        with pytest.raises(DownloadException, match=\"Download failed after 3 attempts\") as exc_info:\n            download_file(\"http://example.com/model.bin\", dest)\n\n        assert \"network error\" in str(exc_info.value).lower()\n        assert mock_stream.call_count == 3\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_http_error_not_retried(self, mock_stream, mock_sleep, tmp_path):\n        \"\"\"Non-200 HTTP status raises DownloadException immediately, no retry.\"\"\"\n        resp = Mock()\n        resp.status_code = 404\n        resp.read.return_value = \"\"\n        resp.__enter__ = Mock(return_value=resp)\n        resp.__exit__ = Mock(return_value=None)\n        mock_stream.return_value = resp\n\n        with pytest.raises(DownloadException, match=\"Failed to download file\"):\n            download_file(\"http://example.com/model.bin\", tmp_path / \"model.bin\")\n\n        assert mock_stream.call_count == 1\n        mock_sleep.assert_not_called()\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_backoff_increases_with_attempts(self, mock_stream, mock_sleep, tmp_path):\n        \"\"\"Retry backoff is 2s, 4s for attempts 1, 2.\"\"\"\n        mock_stream.side_effect = httpx.ReadTimeout(\"timeout\")\n\n        with pytest.raises(DownloadException):\n            download_file(\"http://example.com/model.bin\", tmp_path / \"model.bin\")\n\n        # Two sleeps: after attempt 0 and attempt 1 (not after the last attempt)\n        assert mock_sleep.call_count == 2\n        mock_sleep.assert_any_call(2)  # 2 * (0+1)\n        mock_sleep.assert_any_call(4)  # 2 * (1+1)\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_original_exception_chained(self, mock_stream, mock_sleep, tmp_path):\n        \"\"\"The original httpx exception is chained as __cause__.\"\"\"\n        mock_stream.side_effect = httpx.ReadTimeout(\"the real cause\")\n\n        with pytest.raises(DownloadException) as exc_info:\n            download_file(\"http://example.com/model.bin\", tmp_path / \"model.bin\")\n\n        assert isinstance(exc_info.value.__cause__, httpx.ReadTimeout)\n\n\nclass TestDownloadPartialCleanup:\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_partial_file_removed_after_midstream_timeout(self, mock_stream, mock_sleep, tmp_path):\n        \"\"\"A file partially written before a timeout is cleaned up.\"\"\"\n        resp = Mock()\n        resp.status_code = 200\n        resp.headers = {}\n        resp.iter_bytes = Mock(side_effect=_make_failing_iter(b\"partial data\"))\n        resp.__enter__ = Mock(return_value=resp)\n        resp.__exit__ = Mock(return_value=None)\n        mock_stream.return_value = resp\n\n        dest = tmp_path / \"model.bin\"\n        with pytest.raises(DownloadException):\n            download_file(\"http://example.com/model.bin\", dest)\n\n        assert not dest.exists()\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_partial_file_removed_between_retries(self, mock_stream, mock_sleep, tmp_path):\n        \"\"\"Partial file from a failed attempt doesn't persist into the next attempt.\"\"\"\n        # First attempt: write partial data then timeout\n        fail_resp = Mock()\n        fail_resp.status_code = 200\n        fail_resp.headers = {}\n        fail_resp.iter_bytes = Mock(side_effect=_make_failing_iter(b\"stale\"))\n        fail_resp.__enter__ = Mock(return_value=fail_resp)\n        fail_resp.__exit__ = Mock(return_value=None)\n\n        # Second attempt: success\n        ok_resp = _make_ok_response(content=b\"fresh data\")\n\n        mock_stream.side_effect = [fail_resp, ok_resp]\n\n        dest = tmp_path / \"model.bin\"\n        download_file(\"http://example.com/model.bin\", dest)\n\n        # File should contain only data from the successful attempt\n        assert dest.read_bytes() == b\"fresh data\"\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_preexisting_file_preserved_on_http_error(self, mock_stream, mock_sleep, tmp_path):\n        \"\"\"A pre-existing file at the destination is NOT touched when the server returns an HTTP error.\n\n        HTTP errors are raised before _download_file_httpx opens the output file, so there is no\n        partial download to clean up. The helper must not destroy unrelated pre-existing data.\n        \"\"\"\n        resp = Mock()\n        resp.status_code = 403\n        resp.read.return_value = \"\"\n        resp.__enter__ = Mock(return_value=resp)\n        resp.__exit__ = Mock(return_value=None)\n        mock_stream.return_value = resp\n\n        dest = tmp_path / \"model.bin\"\n        dest.write_bytes(b\"IMPORTANT pre-existing data\")\n\n        with pytest.raises(DownloadException):\n            download_file(\"http://example.com/model.bin\", dest)\n\n        assert dest.exists()\n        assert dest.read_bytes() == b\"IMPORTANT pre-existing data\"\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_preexisting_file_preserved_on_connect_error(self, mock_stream, mock_sleep, tmp_path):\n        \"\"\"A pre-existing file is NOT deleted when all retries fail with a pre-open transient error.\n\n        ConnectError/ConnectTimeout are raised at httpx.stream() entry, before the output file\n        is opened. Cleanup must not run in that case, or it would wipe out an unrelated\n        pre-existing file at the destination path.\n        \"\"\"\n        mock_stream.side_effect = httpx.ConnectError(\"refused\")\n\n        dest = tmp_path / \"model.bin\"\n        dest.write_bytes(b\"IMPORTANT pre-existing data\")\n\n        with pytest.raises(DownloadException, match=\"Download failed after 3 attempts\"):\n            download_file(\"http://example.com/model.bin\", dest)\n\n        assert dest.exists()\n        assert dest.read_bytes() == b\"IMPORTANT pre-existing data\"\n\n    @patch(\"comfy_cli.file_utils.ui.prompt_confirm_action\", return_value=True)\n    @patch(\"httpx.stream\")\n    def test_preexisting_file_preserved_on_interrupt_before_open(self, mock_stream, mock_prompt, tmp_path):\n        \"\"\"KeyboardInterrupt during connection setup (before output file is opened) must not\n        prompt the user or delete an unrelated pre-existing file.\n        \"\"\"\n        mock_stream.side_effect = KeyboardInterrupt()\n\n        dest = tmp_path / \"model.bin\"\n        dest.write_bytes(b\"IMPORTANT pre-existing data\")\n\n        with pytest.raises(KeyboardInterrupt):\n            download_file(\"http://example.com/model.bin\", dest)\n\n        # Prompt should NOT have been shown — the file was never opened this attempt.\n        mock_prompt.assert_not_called()\n        assert dest.exists()\n        assert dest.read_bytes() == b\"IMPORTANT pre-existing data\"\n\n    @patch(\"comfy_cli.file_utils.ui.prompt_confirm_action\", return_value=True)\n    @patch(\"httpx.stream\")\n    def test_keyboard_interrupt_cleans_up_when_user_confirms(self, mock_stream, mock_prompt, tmp_path):\n        \"\"\"On KeyboardInterrupt the user is prompted; confirming removes the partial file and re-raises.\"\"\"\n        resp = Mock()\n        resp.status_code = 200\n        resp.headers = {}\n        resp.iter_bytes = Mock(side_effect=_make_failing_iter(b\"partial\", KeyboardInterrupt()))\n        resp.__enter__ = Mock(return_value=resp)\n        resp.__exit__ = Mock(return_value=None)\n        mock_stream.return_value = resp\n\n        dest = tmp_path / \"model.bin\"\n        with pytest.raises(KeyboardInterrupt):\n            download_file(\"http://example.com/model.bin\", dest)\n\n        mock_prompt.assert_called_once()\n        assert not dest.exists()\n\n    @patch(\"comfy_cli.file_utils.ui.prompt_confirm_action\", return_value=False)\n    @patch(\"httpx.stream\")\n    def test_keyboard_interrupt_keeps_partial_when_user_declines(self, mock_stream, mock_prompt, tmp_path):\n        \"\"\"On KeyboardInterrupt the user is prompted; declining keeps the partial file on disk.\"\"\"\n        resp = Mock()\n        resp.status_code = 200\n        resp.headers = {}\n        resp.iter_bytes = Mock(side_effect=_make_failing_iter(b\"partial data\", KeyboardInterrupt()))\n        resp.__enter__ = Mock(return_value=resp)\n        resp.__exit__ = Mock(return_value=None)\n        mock_stream.return_value = resp\n\n        dest = tmp_path / \"model.bin\"\n        with pytest.raises(KeyboardInterrupt):\n            download_file(\"http://example.com/model.bin\", dest)\n\n        mock_prompt.assert_called_once()\n        assert dest.exists()\n        assert dest.read_bytes() == b\"partial data\"\n\n\nclass TestDownloadHTTPStatusRetry:\n    \"\"\"Retry behavior for transient HTTP status codes (5xx, 429, 408).\"\"\"\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_500_retried_and_succeeds(self, mock_stream, mock_sleep, tmp_path):\n        \"\"\"Download retries on HTTP 500 and succeeds on the next attempt.\"\"\"\n        mock_stream.side_effect = [\n            _make_status_response(500),\n            _make_ok_response(content=b\"ok\"),\n        ]\n\n        dest = tmp_path / \"model.bin\"\n        download_file(\"http://example.com/model.bin\", dest)\n\n        assert dest.read_bytes() == b\"ok\"\n        assert mock_stream.call_count == 2\n        mock_sleep.assert_called_once_with(2)\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_502_retried(self, mock_stream, mock_sleep, tmp_path):\n        mock_stream.side_effect = [\n            _make_status_response(502),\n            _make_ok_response(content=b\"ok\"),\n        ]\n\n        dest = tmp_path / \"model.bin\"\n        download_file(\"http://example.com/model.bin\", dest)\n        assert mock_stream.call_count == 2\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_503_retried(self, mock_stream, mock_sleep, tmp_path):\n        mock_stream.side_effect = [\n            _make_status_response(503),\n            _make_ok_response(content=b\"ok\"),\n        ]\n\n        dest = tmp_path / \"model.bin\"\n        download_file(\"http://example.com/model.bin\", dest)\n        assert mock_stream.call_count == 2\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_504_retried(self, mock_stream, mock_sleep, tmp_path):\n        mock_stream.side_effect = [\n            _make_status_response(504),\n            _make_ok_response(content=b\"ok\"),\n        ]\n\n        dest = tmp_path / \"model.bin\"\n        download_file(\"http://example.com/model.bin\", dest)\n        assert mock_stream.call_count == 2\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_429_retried(self, mock_stream, mock_sleep, tmp_path):\n        mock_stream.side_effect = [\n            _make_status_response(429),\n            _make_ok_response(content=b\"ok\"),\n        ]\n\n        dest = tmp_path / \"model.bin\"\n        download_file(\"http://example.com/model.bin\", dest)\n        assert mock_stream.call_count == 2\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_408_retried(self, mock_stream, mock_sleep, tmp_path):\n        mock_stream.side_effect = [\n            _make_status_response(408),\n            _make_ok_response(content=b\"ok\"),\n        ]\n\n        dest = tmp_path / \"model.bin\"\n        download_file(\"http://example.com/model.bin\", dest)\n        assert mock_stream.call_count == 2\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_all_retries_exhausted_on_500(self, mock_stream, mock_sleep, tmp_path):\n        \"\"\"After 3 failed attempts on 500, a DownloadException is raised with a friendly message.\"\"\"\n        mock_stream.side_effect = [\n            _make_status_response(500),\n            _make_status_response(500),\n            _make_status_response(500),\n        ]\n\n        dest = tmp_path / \"model.bin\"\n        with pytest.raises(DownloadException, match=\"Download failed after 3 attempts\") as exc_info:\n            download_file(\"http://example.com/model.bin\", dest)\n\n        assert \"HTTP 500\" in str(exc_info.value)\n        # The stdlib HTTPStatus phrase is surfaced so the user knows what 500 means.\n        assert \"Internal Server Error\" in str(exc_info.value)\n        assert mock_stream.call_count == 3\n        # The last transient HTTP error must be chained as __cause__ for debuggability.\n        assert isinstance(exc_info.value.__cause__, _TransientHTTPStatusError)\n        assert exc_info.value.__cause__.status_code == 500\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_retry_body_read_timeout_still_retries(self, mock_stream, mock_sleep, tmp_path):\n        \"\"\"If reading the 500 response body itself times out, we still retry the request.\"\"\"\n        fail_resp = Mock()\n        fail_resp.status_code = 500\n        fail_resp.read.side_effect = httpx.ReadTimeout(\"body read timed out\")\n        fail_resp.__enter__ = Mock(return_value=fail_resp)\n        fail_resp.__exit__ = Mock(return_value=None)\n\n        mock_stream.side_effect = [fail_resp, _make_ok_response(content=b\"ok\")]\n\n        dest = tmp_path / \"model.bin\"\n        download_file(\"http://example.com/model.bin\", dest)\n\n        assert dest.read_bytes() == b\"ok\"\n        assert mock_stream.call_count == 2\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_mixed_transient_errors_eventually_succeed(self, mock_stream, mock_sleep, tmp_path):\n        \"\"\"Retries work across a mix of network-level and HTTP-status errors.\"\"\"\n        mock_stream.side_effect = [\n            _make_status_response(503),\n            httpx.ReadTimeout(\"timeout\"),\n            _make_ok_response(content=b\"finally\"),\n        ]\n\n        dest = tmp_path / \"model.bin\"\n        download_file(\"http://example.com/model.bin\", dest)\n\n        assert dest.read_bytes() == b\"finally\"\n        assert mock_stream.call_count == 3\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_404_not_retried(self, mock_stream, mock_sleep, tmp_path):\n        \"\"\"404 fails fast without retry.\"\"\"\n        mock_stream.return_value = _make_status_response(404)\n\n        with pytest.raises(DownloadException, match=\"Failed to download file\"):\n            download_file(\"http://example.com/model.bin\", tmp_path / \"model.bin\")\n\n        assert mock_stream.call_count == 1\n        mock_sleep.assert_not_called()\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_401_not_retried(self, mock_stream, mock_sleep, tmp_path):\n        \"\"\"401 fails fast without retry.\"\"\"\n        mock_stream.return_value = _make_status_response(401)\n\n        with pytest.raises(DownloadException, match=\"Failed to download file\"):\n            download_file(\"http://example.com/model.bin\", tmp_path / \"model.bin\")\n\n        assert mock_stream.call_count == 1\n        mock_sleep.assert_not_called()\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_403_not_retried(self, mock_stream, mock_sleep, tmp_path):\n        \"\"\"403 fails fast without retry.\"\"\"\n        mock_stream.return_value = _make_status_response(403)\n\n        with pytest.raises(DownloadException, match=\"Failed to download file\"):\n            download_file(\"http://example.com/model.bin\", tmp_path / \"model.bin\")\n\n        assert mock_stream.call_count == 1\n        mock_sleep.assert_not_called()\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_preexisting_file_preserved_on_http_status_retry_exhaust(self, mock_stream, mock_sleep, tmp_path):\n        \"\"\"A pre-existing file at the destination is NOT deleted when all retries fail on HTTP 500.\n\n        The retriable HTTP status is raised before _download_file_httpx opens the output file.\n        \"\"\"\n        mock_stream.side_effect = [\n            _make_status_response(500),\n            _make_status_response(500),\n            _make_status_response(500),\n        ]\n\n        dest = tmp_path / \"model.bin\"\n        dest.write_bytes(b\"IMPORTANT pre-existing data\")\n\n        with pytest.raises(DownloadException, match=\"Download failed after 3 attempts\"):\n            download_file(\"http://example.com/model.bin\", dest)\n\n        assert dest.exists()\n        assert dest.read_bytes() == b\"IMPORTANT pre-existing data\"\n\n\nclass TestDownloadNonRetriableHTTPError:\n    \"\"\"Non-retriable httpx errors (UnsupportedProtocol, TooManyRedirects, etc.) are wrapped\n    as DownloadException so callers only need to handle one error type and users don't\n    see a raw Python traceback.\"\"\"\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_unsupported_protocol_wrapped(self, mock_stream, mock_sleep, tmp_path):\n        mock_stream.side_effect = httpx.UnsupportedProtocol(\"Request URL has an unsupported protocol 'ftp://'\")\n\n        with pytest.raises(DownloadException, match=\"Download failed\") as exc_info:\n            download_file(\"ftp://example.com/model.bin\", tmp_path / \"model.bin\")\n\n        assert isinstance(exc_info.value.__cause__, httpx.UnsupportedProtocol)\n        assert mock_stream.call_count == 1\n        mock_sleep.assert_not_called()\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_too_many_redirects_wrapped(self, mock_stream, mock_sleep, tmp_path):\n        mock_stream.side_effect = httpx.TooManyRedirects(\"Exceeded maximum allowed redirects\")\n\n        with pytest.raises(DownloadException, match=\"Download failed\") as exc_info:\n            download_file(\"http://example.com/model.bin\", tmp_path / \"model.bin\")\n\n        assert isinstance(exc_info.value.__cause__, httpx.TooManyRedirects)\n        assert mock_stream.call_count == 1\n        mock_sleep.assert_not_called()\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_decoding_error_wrapped(self, mock_stream, mock_sleep, tmp_path):\n        mock_stream.side_effect = httpx.DecodingError(\"Invalid compressed data\")\n\n        with pytest.raises(DownloadException, match=\"Download failed\") as exc_info:\n            download_file(\"http://example.com/model.bin\", tmp_path / \"model.bin\")\n\n        assert isinstance(exc_info.value.__cause__, httpx.DecodingError)\n        assert mock_stream.call_count == 1\n        mock_sleep.assert_not_called()\n\n    @patch(\"comfy_cli.file_utils.time.sleep\")\n    @patch(\"httpx.stream\")\n    def test_invalid_url_wrapped(self, mock_stream, mock_sleep, tmp_path):\n        \"\"\"httpx.InvalidURL does NOT subclass httpx.HTTPError — it must still be wrapped\n        as DownloadException so a malformed URL doesn't leak as a Typer traceback.\"\"\"\n        mock_stream.side_effect = httpx.InvalidURL(\"Request URL is missing a scheme\")\n\n        with pytest.raises(DownloadException, match=\"Download failed\") as exc_info:\n            download_file(\"no-scheme-url\", tmp_path / \"model.bin\")\n\n        assert isinstance(exc_info.value.__cause__, httpx.InvalidURL)\n        assert \"invalid URL\" in str(exc_info.value)\n        assert mock_stream.call_count == 1\n        mock_sleep.assert_not_called()\n\n    @patch(\"httpx.stream\")\n    def test_invalid_url_preserves_preexisting_file(self, mock_stream, tmp_path):\n        \"\"\"InvalidURL is raised before the output file is opened — any pre-existing\n        file at the destination path must be left intact.\"\"\"\n        mock_stream.side_effect = httpx.InvalidURL(\"bad\")\n\n        dest = tmp_path / \"model.bin\"\n        dest.write_bytes(b\"IMPORTANT pre-existing data\")\n\n        with pytest.raises(DownloadException):\n            download_file(\"not-a-url\", dest)\n\n        assert dest.exists()\n        assert dest.read_bytes() == b\"IMPORTANT pre-existing data\"\n\n    @patch(\"httpx.stream\")\n    def test_preexisting_file_preserved_on_non_retriable_error(self, mock_stream, tmp_path):\n        \"\"\"A non-retriable httpx error before the output file is opened must not delete\n        an unrelated pre-existing file at the destination path.\"\"\"\n        mock_stream.side_effect = httpx.UnsupportedProtocol(\"nope\")\n\n        dest = tmp_path / \"model.bin\"\n        dest.write_bytes(b\"IMPORTANT pre-existing data\")\n\n        with pytest.raises(DownloadException):\n            download_file(\"ftp://example.com/model.bin\", dest)\n\n        assert dest.exists()\n        assert dest.read_bytes() == b\"IMPORTANT pre-existing data\"\n\n    @patch(\"httpx.stream\")\n    def test_partial_file_cleaned_up_on_mid_stream_non_retriable(self, mock_stream, tmp_path):\n        \"\"\"If a non-retriable error is raised AFTER the output file is opened (mid-stream),\n        the partial file is cleaned up.\"\"\"\n        resp = Mock()\n        resp.status_code = 200\n        resp.headers = {\"Content-Length\": \"100\"}\n        resp.iter_bytes = Mock(side_effect=_make_failing_iter(b\"partial\", httpx.DecodingError(\"bad\")))\n        resp.__enter__ = Mock(return_value=resp)\n        resp.__exit__ = Mock(return_value=None)\n        mock_stream.return_value = resp\n\n        dest = tmp_path / \"model.bin\"\n        with pytest.raises(DownloadException):\n            download_file(\"http://example.com/model.bin\", dest)\n\n        assert not dest.exists()\n"
  },
  {
    "path": "tests/uv/mock_comfy/custom_nodes/x/pyproject.toml",
    "content": "\n"
  },
  {
    "path": "tests/uv/mock_comfy/custom_nodes/x/requirements.txt",
    "content": ""
  },
  {
    "path": "tests/uv/mock_comfy/custom_nodes/x/setup.cfg",
    "content": ""
  },
  {
    "path": "tests/uv/mock_comfy/custom_nodes/x/setup.py",
    "content": ""
  },
  {
    "path": "tests/uv/mock_comfy/custom_nodes/y/setup.cfg",
    "content": ""
  },
  {
    "path": "tests/uv/mock_comfy/custom_nodes/y/setup.py",
    "content": ""
  },
  {
    "path": "tests/uv/mock_comfy/custom_nodes/z/setup.py",
    "content": ""
  },
  {
    "path": "tests/uv/mock_comfy/pyproject.toml",
    "content": "\n"
  },
  {
    "path": "tests/uv/mock_comfy/setup.cfg",
    "content": ""
  },
  {
    "path": "tests/uv/mock_comfy/setup.py",
    "content": ""
  },
  {
    "path": "tests/uv/mock_requirements/core_reqs.txt",
    "content": "tqdm==4.66.4\n"
  },
  {
    "path": "tests/uv/mock_requirements/x_reqs.txt",
    "content": "numpy>=2.0.0\nsympy<=1.10.1\ntqdm==1.0\n"
  },
  {
    "path": "tests/uv/mock_requirements/y_reqs.txt",
    "content": "mpmath==1.3.0\nnumpy<=2.0.2\nsympy>=1.13.0\ntqdm==2.0.0\n"
  },
  {
    "path": "tests/uv/test_torch_backend_compile.py",
    "content": "\"\"\"Integration tests for torch backend compilation.\n\nThese tests do real uv pip compile with a torch requirement and verify\nthe compiled output contains the correct torch variant for each backend.\n\nRequires network access. Gated behind TEST_TORCH_BACKEND=true.\n\nPlatform constraints (PyTorch wheel availability):\n  - NVIDIA (cu126): Linux, Windows\n  - AMD (rocm6.1): Linux only\n  - CPU: Linux, Windows, macOS\n  - Default PyPI (mac path): all platforms\n\"\"\"\n\nimport os\nimport shutil\nimport sys\nfrom pathlib import Path\n\nimport pytest\n\nfrom comfy_cli.constants import GPU_OPTION\nfrom comfy_cli.uv import DependencyCompiler\n\npytestmark = pytest.mark.skipif(\n    os.environ.get(\"TEST_TORCH_BACKEND\") != \"true\",\n    reason=\"Set TEST_TORCH_BACKEND=true to run torch backend integration tests\",\n)\n\n_here = Path(__file__).parent.resolve()\n_temp = _here.parent / \"temp\" / \"test_torch_backend\"\n\n\n@pytest.fixture(autouse=True)\ndef _setup_temp():\n    shutil.rmtree(_temp, ignore_errors=True)\n    _temp.mkdir(exist_ok=True, parents=True)\n    (_temp / \"reqs.txt\").write_text(\"torch\\n\")\n\n\ndef _compile_for(gpu):\n    dc = DependencyCompiler(\n        cwd=_temp,\n        gpu=gpu,\n        outDir=_temp,\n        reqFilesCore=[_temp / \"reqs.txt\"],\n        reqFilesExt=[],\n    )\n    dc.make_override()\n    dc.compile_core_plus_ext()\n    return dc.out.read_text()\n\n\n@pytest.mark.skipif(sys.platform == \"darwin\", reason=\"No CUDA wheels for macOS\")\ndef test_compile_nvidia():\n    content = _compile_for(GPU_OPTION.NVIDIA)\n    assert \"+cu126\" in content\n    assert \"download.pytorch.org/whl/cu126\" in content\n    assert \"--extra-index-url\" not in content\n\n\n@pytest.mark.skipif(sys.platform != \"linux\", reason=\"ROCm wheels are Linux-only\")\ndef test_compile_amd():\n    content = _compile_for(GPU_OPTION.AMD)\n    assert \"+rocm\" in content\n    assert \"download.pytorch.org/whl/rocm\" in content\n    assert \"--extra-index-url\" not in content\n\n\n@pytest.mark.skipif(sys.platform == \"darwin\", reason=\"No +cpu variant wheels for macOS\")\ndef test_compile_cpu():\n    content = _compile_for(GPU_OPTION.CPU)\n    assert \"+cpu\" in content\n    assert \"download.pytorch.org/whl/cpu\" in content\n    assert \"--extra-index-url\" not in content\n    # must not pull in nvidia CUDA libraries\n    assert \"nvidia-\" not in content\n\n\n@pytest.mark.skipif(sys.platform != \"linux\", reason=\"CUDA toolkit extras are Linux-only\")\ndef test_compile_nvidia_cu130_preserves_cuda_runtime():\n    \"\"\"Regression test for #412: cu130 compile must include CUDA runtime packages.\n\n    torch >= 2.11 depends on cuda-toolkit[cublas,cudart,...] for CUDA runtime libs.\n    make_override() must not strip these extras, or nvidia-cuda-runtime and\n    nvidia-cuda-nvrtc will be missing from the final compiled output, causing\n    'libcudart.so: cannot open shared object file' at import time.\n    \"\"\"\n    dc = DependencyCompiler(\n        cwd=_temp,\n        gpu=GPU_OPTION.NVIDIA,\n        outDir=_temp,\n        reqFilesCore=[_temp / \"reqs.txt\"],\n        reqFilesExt=[],\n        cuda_version=\"13.0\",\n    )\n    dc.compile_deps()\n    content = dc.out.read_text()\n\n    assert \"+cu130\" in content, \"Expected torch+cu130 in compiled output\"\n\n    # These provide libcudart.so and libnvrtc.so — the exact libraries\n    # reported missing in issue #412\n    assert \"nvidia-cuda-runtime==\" in content, (\n        \"nvidia-cuda-runtime missing from compiled output — \"\n        \"cuda-toolkit extras were likely stripped by the override. \"\n        \"See: https://github.com/Comfy-Org/comfy-cli/issues/412\"\n    )\n    assert \"nvidia-cuda-nvrtc==\" in content, (\n        \"nvidia-cuda-nvrtc missing from compiled output — \"\n        \"cuda-toolkit extras were likely stripped by the override. \"\n        \"See: https://github.com/Comfy-Org/comfy-cli/issues/412\"\n    )\n\n\ndef test_compile_mac():\n    content = _compile_for(GPU_OPTION.MAC_M_SERIES)\n    assert \"torch==\" in content\n    # default PyPI torch — no GPU-specific local version suffix\n    assert \"+cu\" not in content\n    assert \"+rocm\" not in content\n    assert \"+cpu\" not in content\n    assert \"--extra-index-url\" not in content\n"
  },
  {
    "path": "tests/uv/test_uv.py",
    "content": "import shutil\nimport subprocess\nfrom pathlib import Path\nfrom unittest.mock import patch\n\nimport pytest\n\nfrom comfy_cli import ui\nfrom comfy_cli.constants import GPU_OPTION\nfrom comfy_cli.uv import DependencyCompiler, _check_call, parse_req_file\n\nhereDir = Path(__file__).parent.resolve()\nmockComfyDir = hereDir / \"mock_comfy\"\nmockReqsDir = hereDir / \"mock_requirements\"\n\n# set up a temp dir to write files to\ntestsDir = hereDir.parent.resolve()\ntemp = testsDir / \"temp\" / \"test_uv\"\nshutil.rmtree(temp, ignore_errors=True)\ntemp.mkdir(exist_ok=True, parents=True)\n\n\n@pytest.fixture\ndef mock_prompt_select(monkeypatch):\n    mockChoices = [\"==1.13.0\", \"==2.0.0\"]\n\n    def _mock_prompt_select(*args, **kwargs):\n        return mockChoices.pop(0)\n\n    monkeypatch.setattr(ui, \"prompt_select\", _mock_prompt_select)\n\n\ndef test_find_req_files():\n    mockNodesDir = mockComfyDir / \"custom_nodes\"\n\n    knownReqFilesCore = [mockComfyDir / \"pyproject.toml\"]\n    knownReqFilesExt = sorted(\n        [\n            mockNodesDir / \"x\" / \"requirements.txt\",\n            mockNodesDir / \"y\" / \"setup.cfg\",\n            mockNodesDir / \"z\" / \"setup.py\",\n        ]\n    )\n\n    depComp = DependencyCompiler(cwd=mockComfyDir)\n\n    testReqFilesCore = depComp.reqFilesCore\n    testReqFilesExt = sorted(depComp.reqFilesExt)\n\n    assert knownReqFilesCore == testReqFilesCore\n    assert knownReqFilesExt == testReqFilesExt\n\n\ndef test_compile(mock_prompt_select):\n    depComp = DependencyCompiler(\n        cwd=temp,\n        gpu=GPU_OPTION.AMD,\n        outDir=temp,\n        reqFilesCore=[mockReqsDir / \"core_reqs.txt\"],\n        reqFilesExt=[mockReqsDir / \"x_reqs.txt\", mockReqsDir / \"y_reqs.txt\"],\n    )\n\n    depComp.make_override()\n    depComp.compile_core_plus_ext()\n\n    with open(mockReqsDir / \"requirements.compiled\") as known, open(temp / \"requirements.compiled\") as test:\n        # compare all non-commented lines in generated file vs reference file\n        knownLines, testLines = [\n            [line for line in known.readlines() if not line.strip().startswith(\"#\")],\n            [line for line in test.readlines() if not line.strip().startswith(\"#\")],\n        ]\n\n        optionalPrefixes = (\"colorama==\",)\n\n        def _filter_optional(lines: list[str]) -> list[str]:\n            # drop platform-specific extras (Windows pulls in colorama via tqdm)\n            return [line for line in lines if not any(line.strip().startswith(prefix) for prefix in optionalPrefixes)]\n\n        knownLines, testLines = [_filter_optional(lines) for lines in (knownLines, testLines)]\n\n        assert knownLines == testLines\n\n\ndef test_torch_backend_nvidia():\n    depComp = DependencyCompiler(cwd=temp, gpu=GPU_OPTION.NVIDIA, outDir=temp, reqFilesCore=[], reqFilesExt=[])\n    assert depComp.torchBackend == \"cu126\"\n    assert depComp.gpuUrl == DependencyCompiler.nvidiaPytorchUrl\n\n\ndef test_torch_backend_amd():\n    depComp = DependencyCompiler(cwd=temp, gpu=GPU_OPTION.AMD, outDir=temp, reqFilesCore=[], reqFilesExt=[])\n    assert depComp.torchBackend == \"rocm6.3\"\n    assert depComp.gpuUrl == DependencyCompiler.rocmPytorchUrl\n\n\ndef test_torch_backend_cpu():\n    depComp = DependencyCompiler(cwd=temp, gpu=GPU_OPTION.CPU, outDir=temp, reqFilesCore=[], reqFilesExt=[])\n    assert depComp.torchBackend == \"cpu\"\n    assert depComp.gpuUrl == DependencyCompiler.cpuPytorchUrl\n\n\ndef test_torch_backend_none():\n    with patch.object(DependencyCompiler, \"Resolve_Gpu\", return_value=None):\n        depComp = DependencyCompiler(cwd=temp, gpu=None, outDir=temp, reqFilesCore=[], reqFilesExt=[])\n    assert depComp.torchBackend is None\n    assert depComp.gpuUrl is None\n\n\ndef test_compile_passes_torch_backend():\n    \"\"\"Verify that Compile() includes --torch-backend in the command when provided.\"\"\"\n    with patch(\"comfy_cli.uv._run\") as mock_run:\n        mock_run.return_value = type(\"R\", (), {\"stdout\": \"\", \"stderr\": \"\", \"returncode\": 0})()\n        DependencyCompiler.Compile(\n            cwd=temp,\n            reqFiles=[mockReqsDir / \"core_reqs.txt\"],\n            torch_backend=\"cu126\",\n        )\n    cmd = mock_run.call_args[0][0]\n    idx = cmd.index(\"--torch-backend\")\n    assert cmd[idx + 1] == \"cu126\"\n\n\ndef test_compile_omits_torch_backend_when_none():\n    \"\"\"Verify that Compile() does not include --torch-backend when torch_backend is None.\"\"\"\n    with patch(\"comfy_cli.uv._run\") as mock_run:\n        mock_run.return_value = type(\"R\", (), {\"stdout\": \"\", \"stderr\": \"\", \"returncode\": 0})()\n        DependencyCompiler.Compile(\n            cwd=temp,\n            reqFiles=[mockReqsDir / \"core_reqs.txt\"],\n            torch_backend=None,\n        )\n    cmd = mock_run.call_args[0][0]\n    assert \"--torch-backend\" not in cmd\n\n\ndef test_compiled_output_has_no_extra_index_url(mock_prompt_select):\n    \"\"\"The compiled output must not contain --extra-index-url (torch-backend handles routing).\"\"\"\n    depComp = DependencyCompiler(\n        cwd=temp,\n        gpu=GPU_OPTION.AMD,\n        outDir=temp,\n        reqFilesCore=[mockReqsDir / \"core_reqs.txt\"],\n        reqFilesExt=[mockReqsDir / \"x_reqs.txt\", mockReqsDir / \"y_reqs.txt\"],\n    )\n    depComp.make_override()\n    depComp.compile_core_plus_ext()\n\n    content = depComp.out.read_text()\n    assert \"--extra-index-url\" not in content\n\n\ndef test_override_file_has_no_extra_index_url():\n    depComp = DependencyCompiler(\n        cwd=temp,\n        gpu=GPU_OPTION.AMD,\n        outDir=temp,\n        reqFilesCore=[mockReqsDir / \"core_reqs.txt\"],\n        reqFilesExt=[],\n    )\n    depComp.make_override()\n\n    content = depComp.override.read_text()\n    assert \"--extra-index-url\" not in content\n    assert \"torch\" in content\n\n\ndef test_make_override_does_not_strip_cuda_toolkit_extras():\n    \"\"\"Regression test for #412: override must not pin cuda-toolkit without extras.\n\n    torch >= 2.11 depends on cuda-toolkit[cublas,cudart,...]==13.0.2.\n    make_override() appends the first compile's flat output to override.txt,\n    which writes 'cuda-toolkit==13.0.2' (no extras). When compile_core_plus_ext()\n    uses this override, uv replaces torch's extras-bearing requirement with the\n    bare pin, silently dropping nvidia-cuda-runtime, nvidia-cuda-nvrtc, and\n    8 other CUDA packages.\n    \"\"\"\n    # Simulate torch >= 2.11 first-compile output (flat pins, no extras)\n    mock_stdout = \"\\n\".join(\n        [\n            \"cuda-bindings==13.2.0\",\n            \"cuda-pathfinder==1.5.1\",\n            \"cuda-toolkit==13.0.2\",\n            \"nvidia-cublas==13.1.0.3\",\n            \"nvidia-cuda-cupti==13.0.85\",\n            \"nvidia-cuda-nvrtc==13.0.88\",\n            \"nvidia-cuda-runtime==13.0.96\",\n            \"nvidia-cudnn-cu13==9.19.0.56\",\n            \"nvidia-cufft==12.0.0.61\",\n            \"nvidia-cufile==1.15.1.6\",\n            \"nvidia-curand==10.4.0.35\",\n            \"nvidia-cusolver==12.0.4.66\",\n            \"nvidia-cusparse==12.6.3.3\",\n            \"nvidia-cusparselt-cu13==0.8.0\",\n            \"nvidia-nccl-cu13==2.28.9\",\n            \"nvidia-nvjitlink==13.0.88\",\n            \"nvidia-nvshmem-cu13==3.4.5\",\n            \"nvidia-nvtx==13.0.85\",\n            \"torch==2.11.0+cu130\",\n            \"torchaudio==2.11.0+cu130\",\n            \"torchsde==0.2.6\",\n            \"torchvision==0.26.0+cu130\",\n            \"\",\n        ]\n    )\n    mock_result = type(\"R\", (), {\"stdout\": mock_stdout, \"stderr\": \"\", \"returncode\": 0})()\n\n    depComp = DependencyCompiler(\n        cwd=temp,\n        gpu=GPU_OPTION.NVIDIA,\n        outDir=temp,\n        reqFilesCore=[mockReqsDir / \"core_reqs.txt\"],\n        reqFilesExt=[],\n        cuda_version=\"13.0\",\n    )\n\n    with patch.object(DependencyCompiler, \"Compile\", return_value=mock_result):\n        depComp.make_override()\n\n    override_content = depComp.override.read_text()\n\n    # The override must not contain a bare 'cuda-toolkit==X.Y.Z' pin.\n    # If cuda-toolkit appears, it must include extras like [cublas,cudart,...].\n    # A bare pin causes uv --override to replace torch's extras-bearing\n    # requirement, dropping all extras-only transitive CUDA runtime packages.\n    for line in override_content.splitlines():\n        stripped = line.strip()\n        if stripped.startswith(\"cuda-toolkit==\"):\n            pytest.fail(\n                f\"Override contains bare cuda-toolkit pin without extras: {stripped!r}\\n\"\n                \"This causes uv to strip extras from torch's cuda-toolkit dependency, \"\n                \"dropping nvidia-cuda-runtime, nvidia-cuda-nvrtc, and other CUDA packages.\\n\"\n                \"See: https://github.com/Comfy-Org/comfy-cli/issues/412\"\n            )\n\n\ndef test_nvidia_custom_cuda_version():\n    depComp = DependencyCompiler(\n        cwd=temp, gpu=GPU_OPTION.NVIDIA, outDir=temp, reqFilesCore=[], reqFilesExt=[], cuda_version=\"11.8\"\n    )\n    assert depComp.torchBackend == \"cu118\"\n    assert depComp.gpuUrl == \"https://download.pytorch.org/whl/cu118\"\n\n\ndef test_nvidia_cuda_13():\n    depComp = DependencyCompiler(\n        cwd=temp, gpu=GPU_OPTION.NVIDIA, outDir=temp, reqFilesCore=[], reqFilesExt=[], cuda_version=\"13.0\"\n    )\n    assert depComp.torchBackend == \"cu130\"\n    assert depComp.gpuUrl == \"https://download.pytorch.org/whl/cu130\"\n\n\ndef test_amd_custom_rocm_version():\n    depComp = DependencyCompiler(\n        cwd=temp, gpu=GPU_OPTION.AMD, outDir=temp, reqFilesCore=[], reqFilesExt=[], rocm_version=\"7.1\"\n    )\n    assert depComp.torchBackend == \"rocm7.1\"\n    assert depComp.gpuUrl == \"https://download.pytorch.org/whl/rocm7.1\"\n\n\ndef test_nvidia_auto_detected_tag():\n    depComp = DependencyCompiler(\n        cwd=temp, gpu=GPU_OPTION.NVIDIA, outDir=temp, reqFilesCore=[], reqFilesExt=[], cuda_version=\"12.8\"\n    )\n    assert depComp.torchBackend == \"cu128\"\n    assert depComp.gpuUrl == \"https://download.pytorch.org/whl/cu128\"\n\n\ndef test_nvidia_no_cuda_version_uses_default():\n    depComp = DependencyCompiler(\n        cwd=temp, gpu=GPU_OPTION.NVIDIA, outDir=temp, reqFilesCore=[], reqFilesExt=[], cuda_version=None\n    )\n    assert depComp.torchBackend == DependencyCompiler.nvidiaTorchBackend\n    assert depComp.gpuUrl == DependencyCompiler.nvidiaPytorchUrl\n\n\n@pytest.mark.parametrize(\"gpu\", [GPU_OPTION.NVIDIA, GPU_OPTION.AMD, GPU_OPTION.CPU])\ndef test_skip_torch_disables_gpu_url_and_backend(gpu):\n    depComp = DependencyCompiler(cwd=temp, gpu=gpu, outDir=temp, reqFilesCore=[], reqFilesExt=[], skip_torch=True)\n    assert depComp.torchBackend is None\n    assert depComp.gpuUrl is None\n\n\ndef test_skip_torch_override_has_no_torch():\n    depComp = DependencyCompiler(\n        cwd=temp,\n        gpu=GPU_OPTION.NVIDIA,\n        outDir=temp,\n        reqFilesCore=[mockReqsDir / \"core_reqs.txt\"],\n        reqFilesExt=[],\n        skip_torch=True,\n    )\n    depComp.make_override()\n    content = depComp.override.read_text()\n    assert \"torch\" not in content\n\n\ndef test_skip_torch_install_deps_no_extra_index_url():\n    depComp = DependencyCompiler(\n        cwd=temp, gpu=GPU_OPTION.NVIDIA, outDir=temp, reqFilesCore=[], reqFilesExt=[], skip_torch=True\n    )\n    depComp.out.write_text(\"requests==2.31.0\\n\")\n    with patch(\"comfy_cli.uv._check_call\") as mock_check_call:\n        depComp.install_deps()\n    cmd = mock_check_call.call_args[0][0]\n    assert \"--extra-index-url\" not in cmd\n\n\ndef test_check_call_prints_nfs_hint_on_uv_install_failure(capsys):\n    \"\"\"When a uv pip install command fails, _check_call should print an NFS hint.\"\"\"\n    cmd = [\"python\", \"-m\", \"uv\", \"pip\", \"install\", \"--requirement\", \"reqs.txt\"]\n    with patch(\"subprocess.check_call\", side_effect=subprocess.CalledProcessError(2, cmd)):\n        with pytest.raises(subprocess.CalledProcessError):\n            _check_call(cmd)\n\n    captured = capsys.readouterr().out\n    assert \"network filesystem\" in captured\n    assert \"UV_LINK_MODE\" in captured\n    assert \"UV_CACHE_DIR\" in captured\n\n\ndef test_check_call_prints_nfs_hint_on_uv_sync_failure(capsys):\n    \"\"\"When a uv pip sync command fails, _check_call should print an NFS hint.\"\"\"\n    cmd = [\"python\", \"-m\", \"uv\", \"pip\", \"sync\", \"reqs.txt\"]\n    with patch(\"subprocess.check_call\", side_effect=subprocess.CalledProcessError(2, cmd)):\n        with pytest.raises(subprocess.CalledProcessError):\n            _check_call(cmd)\n\n    captured = capsys.readouterr().out\n    assert \"network filesystem\" in captured\n\n\ndef test_check_call_no_hint_for_non_uv_failure(capsys):\n    \"\"\"Non-uv commands should not trigger the NFS hint.\"\"\"\n    cmd = [\"python\", \"-m\", \"pip\", \"install\", \"requests\"]\n    with patch(\"subprocess.check_call\", side_effect=subprocess.CalledProcessError(1, cmd)):\n        with pytest.raises(subprocess.CalledProcessError):\n            _check_call(cmd)\n\n    captured = capsys.readouterr().out\n    assert \"network filesystem\" not in captured\n\n\ndef test_check_call_no_hint_on_uv_compile_failure(capsys):\n    \"\"\"uv pip compile failures should not trigger the NFS hint (only install/sync).\"\"\"\n    cmd = [\"python\", \"-m\", \"uv\", \"pip\", \"compile\", \"reqs.in\"]\n    with patch(\"subprocess.check_call\", side_effect=subprocess.CalledProcessError(1, cmd)):\n        with pytest.raises(subprocess.CalledProcessError):\n            _check_call(cmd)\n\n    captured = capsys.readouterr().out\n    assert \"network filesystem\" not in captured\n\n\ndef test_check_call_no_hint_for_pip_install_uv(capsys):\n    \"\"\"'pip install uv' must not trigger the hint even though 'uv' and 'install' are both present.\"\"\"\n    cmd = [\"python\", \"-m\", \"pip\", \"install\", \"--upgrade\", \"pip\", \"uv\"]\n    with patch(\"subprocess.check_call\", side_effect=subprocess.CalledProcessError(1, cmd)):\n        with pytest.raises(subprocess.CalledProcessError):\n            _check_call(cmd)\n\n    captured = capsys.readouterr().out\n    assert \"network filesystem\" not in captured\n\n\n# Issue #431: parse_req_file feeds its output into pip argv (pip download /\n# pip wheel). Inline comments would be rejected by pip; VCS URL fragments must\n# be preserved verbatim (e.g. `#subdirectory=pkg`, `#egg=foo`).\n\n\ndef test_parse_req_file_strips_inline_comments(tmp_path):\n    rf = tmp_path / \"requirements.txt\"\n    rf.write_text(\"foo>=1.0  # trailing comment\\n\")\n    assert parse_req_file(rf) == [\"foo>=1.0\"]\n\n\ndef test_parse_req_file_strips_inline_comment_with_single_space(tmp_path):\n    rf = tmp_path / \"requirements.txt\"\n    rf.write_text(\"bar==2.3 # single space before hash\\n\")\n    assert parse_req_file(rf) == [\"bar==2.3\"]\n\n\ndef test_parse_req_file_skips_full_line_comments(tmp_path):\n    rf = tmp_path / \"requirements.txt\"\n    rf.write_text(\"# heading\\nfoo>=1.0\\n   # indented heading\\nbaz\\n\")\n    assert parse_req_file(rf) == [\"foo>=1.0\", \"baz\"]\n\n\ndef test_parse_req_file_preserves_vcs_subdirectory_fragment(tmp_path):\n    # Regression guard: any naive `split(\"#\")[0]` would break this. `#` is only\n    # a comment marker when preceded by whitespace (pip's rule).\n    rf = tmp_path / \"requirements.txt\"\n    rf.write_text(\"git+https://github.com/org/mono.git#subdirectory=pkg\\n\")\n    assert parse_req_file(rf) == [\"git+https://github.com/org/mono.git#subdirectory=pkg\"]\n\n\ndef test_parse_req_file_preserves_vcs_egg_fragment(tmp_path):\n    rf = tmp_path / \"requirements.txt\"\n    rf.write_text(\"git+https://github.com/org/repo.git@main#egg=foo\\n\")\n    assert parse_req_file(rf) == [\"git+https://github.com/org/repo.git@main#egg=foo\"]\n\n\ndef test_parse_req_file_preserves_direct_url_hash(tmp_path):\n    rf = tmp_path / \"requirements.txt\"\n    rf.write_text(\"foo @ https://host/f.whl#sha256=abc123\\n\")\n    assert parse_req_file(rf) == [\"foo @ https://host/f.whl#sha256=abc123\"]\n\n\ndef test_parse_req_file_vcs_with_inline_comment_strips_only_comment(tmp_path):\n    # The trickiest case: a VCS spec with a fragment AND a trailing comment.\n    # Comment is preceded by whitespace so it must be stripped; fragment is\n    # part of the URL and must survive.\n    rf = tmp_path / \"requirements.txt\"\n    rf.write_text(\"git+https://host/r.git#subdirectory=pkg  # note\\n\")\n    assert parse_req_file(rf) == [\"git+https://host/r.git#subdirectory=pkg\"]\n\n\ndef test_parse_req_file_preserves_double_dash_options(tmp_path):\n    rf = tmp_path / \"requirements.txt\"\n    rf.write_text(\"--extra-index-url https://example.com/simple\\nfoo\\n\")\n    assert parse_req_file(rf) == [\"--extra-index-url\", \"https://example.com/simple\", \"foo\"]\n\n\ndef test_parse_req_file_handles_crlf_line_endings(tmp_path):\n    # Windows-authored requirements.txt files use CRLF. Verify the comment\n    # stripper + .strip() cleanly handles the trailing \\r.\n    rf = tmp_path / \"requirements.txt\"\n    rf.write_bytes(b\"foo>=1.0  # note\\r\\nbar>=2.0\\r\\n\")\n    assert parse_req_file(rf) == [\"foo>=1.0\", \"bar>=2.0\"]\n"
  }
]