Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
324 changes: 270 additions & 54 deletions comfy_cli/command/generate/app.py

Large diffs are not rendered by default.

22 changes: 20 additions & 2 deletions comfy_cli/command/generate/poll.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,24 @@ def _sleep(seconds: float) -> None:
time.sleep(seconds)


def _decode_poll_body(resp: httpx.Response) -> dict[str, Any]:
"""Parse a 200 poll response, turning an unparseable body into an ``ApiError``.

A proxy/CDN that answers 200 with an HTML error page (or an empty body)
used to raise a bare ``ValueError`` out of ``resp.json()``, which is neither
``ApiError`` nor ``httpx.HTTPError`` — so every caller's ``except`` tuple
missed it and the run died as an unhandled traceback. Raising ``ApiError``
puts it back on the path that already reports poll failures.
"""
try:
body = resp.json()
except ValueError as e:
raise client.ApiError(resp.status_code, resp.text, f"Poll response was not JSON: {resp.text[:200]!r}") from e
if not isinstance(body, dict):
raise client.ApiError(resp.status_code, resp.text, f"Poll response was not a JSON object: {body!r}")
return body


def poll_bfl(
initial: dict[str, Any],
api_key: str,
Expand All @@ -129,7 +147,7 @@ def poll_bfl(
resp = client.get(url, api_key=api_key)
if resp.status_code >= 400:
client.raise_for_status(resp)
last_body = resp.json()
last_body = _decode_poll_body(resp)
status = str(last_body.get("status", "")).strip()
if on_progress is not None:
progress = last_body.get("progress")
Expand Down Expand Up @@ -282,7 +300,7 @@ def poll_generic(
resp = client.get(url, api_key=api_key)
if resp.status_code >= 400:
client.raise_for_status(resp)
last_body = resp.json()
last_body = _decode_poll_body(resp)
if on_progress is not None and spec.progress_path:
p = _dotget(last_body, spec.progress_path)
if isinstance(p, int | float):
Expand Down
53 changes: 49 additions & 4 deletions comfy_cli/error_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -630,10 +630,55 @@ class ErrorCode:
),
# --- generate / emit -----------------------------------------------------
ErrorCode(
"generate_model_unknown",
"`comfy generate schema <model>` got a name that is neither a known alias nor a curated "
"endpoint id. `details.requested` carries the name as typed; the message lists close matches.",
"run `comfy generate list` to see the available model aliases",
"generate_target_required",
"`comfy generate` was invoked with a flag token where its first positional argument (the "
"partner model alias) belongs — e.g. `comfy generate --prompt=x`. `generate` is a "
"cloud/partner verb that spends credits; it always needs a model alias first.",
'name a model alias first (`comfy generate flux-pro --prompt "…"`, `comfy generate list` to '
"browse them), or use `comfy run-template` for local text-to-image",
),
ErrorCode(
"generate_unknown_model",
"The model alias/id passed to `comfy generate` (or `generate schema` / `generate resume`) is "
"not in the partner-endpoint catalog.",
"run `comfy generate list` to see available models; `comfy generate refresh` re-fetches the catalog",
),
ErrorCode(
"generate_bad_args",
"`comfy generate` could not parse its arguments: a missing/malformed flag value, a missing "
"required model parameter, a bad subcommand usage, or a resume of a non-polling model.",
"run `comfy generate schema <model>` for the parameter list, or `comfy generate --help` for usage",
),
ErrorCode(
"generate_timeout_invalid",
"`comfy generate --timeout` was given a value that isn't a number.",
"pass seconds as a number, e.g. `--timeout 300`",
),
ErrorCode(
"generate_api_error",
"The partner-proxy API rejected the call or returned an unusable response (auth failure, "
"non-2xx status, non-JSON body). `details.status` / `details.body` carry the response when "
"the failure was an HTTP status.",
"check `comfy cloud login` / COMFY_API_KEY and the reported status; retry if it was a 5xx",
),
ErrorCode(
"generate_network_error",
"A transport-level failure (DNS, TLS, connect, read timeout) while talking to the partner "
"proxy — the request may never have reached it.",
"check network connectivity and retry; raise `--timeout` if the model is slow",
),
ErrorCode(
"generate_job_failed",
"The partner job reached a terminal non-succeeded state (failed/cancelled). "
"`details.response` carries the raw partner response.",
"check `details.response` for the partner's reason; fix the inputs and re-run, or "
"`comfy generate resume <model> <job_id>` if the job may still settle",
),
ErrorCode(
"generate_spec_invalid",
"`comfy generate refresh` fetched an OpenAPI document that failed validation, so it was "
"refused rather than cached over the working catalog.",
"check COMFY_API_BASE_URL points at the Comfy API; the existing cached catalog is still usable",
),
ErrorCode(
"emit_workflow_failed",
Expand Down
16 changes: 14 additions & 2 deletions comfy_cli/skills/comfy/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -669,8 +669,8 @@ Mechanical contracts that bite agents — encode them, don't rediscover:
`generate_schema` in `comfy discover`). `data.models[]` carries
`{alias, id, partner, category, mode, summary}` with the **untruncated**
summary; `data.params[]` carries `{name, type, required, default, enum,
description}`. Unknown model → `generate_model_unknown`; missing model arg →
`missing_argument`. This catalog is **not** in `comfy discover` — `generate
description}`. Unknown model → `generate_unknown_model`; missing model arg →
`generate_bad_args`. This catalog is **not** in `comfy discover` — `generate
list` is the only source of the alias list.
- **Machine-readable output:** `generate <model> --json` prints the raw API
response as JSON; `generate upload <file> --json` emits structured
Expand All @@ -679,6 +679,18 @@ Mechanical contracts that bite agents — encode them, don't rediscover:
error code `emit_workflow_failed`) — the output is a runnable partner-node
workflow you can compose with (fragments+`run` route, no extra API key).
The default pretty path (no flags) is still human-only — do not parse it.
- **Failures always come back as an envelope under global `--json`.** Any
failed/malformed `comfy --json generate …` emits exactly one `envelope/1`
error on stdout with a stable code — `generate_target_required`,
`generate_unknown_model`, `generate_bad_args`, `generate_timeout_invalid`,
`generate_api_error`, `generate_network_error`, `generate_job_failed`,
`generate_spec_invalid`, `spend_consent_required` — so branch on
`error.code`, never on the text.
(Success payloads are unchanged: still the raw API response, not an
envelope.) In particular `comfy generate --prompt "…"` with no model alias
is `generate_target_required`: `generate` is a paid cloud/partner verb and
always needs an alias first. For **local** text-to-image, use
`comfy run-template` instead.
- **`--emit-workflow` resolves the escape-hatch vs. quality tradeoff:**
fragments+`run` is the default for graph work; `generate` is the
highest-quality single-shot for partner models. With
Expand Down
24 changes: 24 additions & 0 deletions tests/comfy_cli/command/generate/test_app_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,30 @@ def test_resume_flag_like_job_id_surfaces_usage_error(self, runner, captured_eve
assert r.exit_code == 1
assert "Usage: comfy generate resume" in r.output

@pytest.mark.parametrize(
("argv", "usage"),
[
(["--no-json", "generate", "upload"], "Usage: comfy generate upload <file-or-url> [--json]"),
(
["--no-json", "generate", "resume"],
"Usage: comfy generate resume <model> <job_id> [--download PATH] [--json]",
),
],
)
def test_usage_errors_render_bracketed_flags_literally(self, runner, captured_events, argv, usage):
# `--no-json` is load-bearing: under CliRunner stdout is not a TTY, so the
# renderer defaults to JSON (renderer.py precedence rule 6) and the assertion
# would pass on the envelope's `message` field without ever rendering markup.
# The pretty branch is what an interactive user actually sees, and it feeds
# the text through Rich — `[--json]` / `[--download PATH]` survive only
# because `_fail` runs `message` through `sanitize_markup`. A hand-rolled
# unescaped `pretty=` override would swallow those brackets (or raise
# MarkupError on a tag-shaped token); this pins the rendered line so that
# regresses loudly.
r = runner.invoke(cli_app, argv)
assert r.exit_code == 1
assert usage in r.output

def test_refresh_fires_generate_refresh(self, runner, captured_events, monkeypatch):
# Mock the httpx call so we don't actually hit the network.
monkeypatch.setattr(
Expand Down
Loading
Loading