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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,34 @@ notes for each version.

### Added

- **The queued model surface** — `client.models.submit()`, `client.models.subscribe()`
and `client.models.handle()`, plus the `RequestHandle` they hand back
(`status()`, `get()`, `cancel()`, `iter_events()`). `models.run` holds one
connection open until the generation is finished; `submit` returns as soon as
the server accepts the request, and the generation is collected later —
including from another process, since `client.models.handle(model,
request_id)` rebuilds the handle from nothing but the two ids. `subscribe` is
submit plus polling plus collection in one call, with an `on_queue_update=`
callback for progress and a client-side `timeout=` that makes a best-effort
cancel before it raises. `AsyncComfy` awaits the same method names with the
same arguments in the same order — there is no `submit_async`, for the reason
there is no `run_async`. Three properties are contract rather than
implementation: polling is authoritative and paced by the server's own
`Retry-After` when it names one; a `COMPLETED` status carrying an
`error_type` — which is how the server reports a failed *or* a cancelled
request — raises the typed `RouterError` subclass rather than being returned
as a successful result; and each `submit` **call** mints one fresh
`Idempotency-Key`, so two deliberate submits are two requests while a
transport retry inside one call replays the original. The queue itself stays
entirely server-owned: ordering, admission, retries, timeouts, billing and
expiry are not reimplemented here. The surface is gated server side — a
caller it is not switched on for is answered `403 not_enabled`, which arrives
as `comfy_sdk.router_exceptions.NotEnabled`. `client.models.run` is unchanged
in behaviour and signature.
- `comfy_sdk.router_exceptions.error_from_completion()` — the typed exception a
completed-but-failed queued request reports, or `None`. Public because the
rule it encodes ("a `200` is not the same thing as a success on this
surface") is one a caller reading a raw payload has to apply too.
- `Asset.get_download_url()` / `AsyncAsset.get_download_url()` — a
directly-fetchable URL for an *uploaded* asset's bytes, mirroring
`Output.get_download_url()` (same `DownloadUrl`, commits the asset first if
Expand Down
119 changes: 114 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,9 @@ client's `COMFY_BASE_URL`. See
two variables.

`base_url` and `timeout` are a read-only view of that configuration; model
operations are added to this namespace as they land.
operations are added to this namespace as they land. There are two ways to run
a model on it — `run`, which waits, and `submit`, which queues — and they send
the same request.

### `models.run` — one call, one result

Expand Down Expand Up @@ -507,6 +509,103 @@ indefinitely. Each call also sends a fresh `Idempotency-Key`, so an accidental
exact resend is rejected by the server instead of billing a second generation;
pass `idempotency_key=` to choose the value yourself.

### `models.submit` — queue it, collect it later

`run` holds one connection open until the generation is finished. When the
caller cannot wait that long — a web request that has to return now, a worker
that submits in one process and collects in another, a batch that should be in
flight all at once — submit it to the queue instead:

```python
handle = client.models.submit("fal-ai/flux-pro", {"prompt": "a cat"})

handle.request_id # a UUID; with the model id, all another process needs
handle.status().status # 'IN_QUEUE' / 'IN_PROGRESS' / 'COMPLETED'
result = handle.get() # blocks until complete, returns the provider payload
```

`submit` sends the same request `run` does — the same model id, the same native
body — and returns as soon as the server has **accepted** it. The queue is the
server's: ordering, admission, retries, timeouts, billing and expiry are all
decided there, and this SDK adds polling and ergonomics on top of it and
nothing else.

The handle carries four operations:

| | |
|---|---|
| `handle.status()` | one authoritative poll, returned as a `QueueUpdate` (`status`, `queue_position`, `error_type`, `retry_after`, `raw`) |
| `handle.get(timeout=None)` | poll to completion, then return the provider's own payload — the same value `run` would have returned |
| `handle.cancel()` | ask the server to cancel. A request, not a guarantee: a request that already completed stays completed |
| `handle.iter_events(timeout=None)` | the poll loop with its updates exposed — yields the first observation, every change of status or queue position, and the completion |

Polling is **poll-authoritative**: there is no stream to reconcile against on
this surface, and `iter_events` is the poll loop rather than SSE. It backs off
adaptively, and a `Retry-After` the server names on a poll beats that schedule
— the server knows its own pace.

**A `200` is not the same thing as a success here.** The server reports a
failed *and* a cancelled request as `COMPLETED` carrying an `error_type`, so
`get()` raises the matching typed exception from
[`comfy_sdk.router_exceptions`](#typed-errors) rather than handing the failure
back as a result. `iter_events` deliberately does not raise — it is a view of
the queue's progress, and `get()` is the one that collects.

Rehydrate a handle in another process from the two ids that address the
request, with no call made:

```python
handle = client.models.handle("fal-ai/flux-pro", request_id)
result = handle.get()
```

Both ids are needed because both address the route
(`/v2/models/{provider}/{model}/requests/{request_id}`), and both are validated
locally before anything is sent.

### `models.subscribe` — submit, follow, collect

```python
def on_update(update):
print(update.status, update.queue_position)

result = client.models.subscribe(
"fal-ai/flux-pro", {"prompt": "a cat"}, on_queue_update=on_update, timeout=300
)
```

`submit` + poll + `get`, in one call, for a caller who does want to wait but
also wants to show progress. `timeout=` is a **client-side** bound with no
server-side meaning; when it runs out, `subscribe` makes a best-effort
`cancel()` — so a caller who has stopped waiting is not still paying for a
generation nobody will collect — and then raises `TimeoutError`. Use `submit`
when the request should outlive the caller's patience.

Each `submit` **call** mints one fresh `Idempotency-Key`: two deliberate
submits of the same input are two requests, while a transport-level retry
inside one call keeps the one key and replays the original rather than queueing
a second generation. Pass `idempotency_key=` to choose it yourself — the case
that earns it is a lost response, where the request may have been accepted and
its id lost with the reply; every exception carries the key on
`.idempotency_key` for exactly that.

The awaitable form is the async client, with the identical names, arguments and
argument order — there is no `submit_async`, for the same reason there is no
`run_async`:

```python
async with AsyncComfy(api_key="comfyui-...") as client:
handle = await client.models.submit("fal-ai/flux-pro", {"prompt": "a cat"})
async for update in handle.iter_events():
print(update.status)
result = await handle.get()
```

This surface is **gated server side**. A caller the queue is not switched on
for is answered `403 not_enabled`, which arrives as
`comfy_sdk.router_exceptions.NotEnabled` — nothing about the request is wrong,
and it is terminal: do not retry it.

### Retrying a run without paying for it twice

A failed `models.run` is retried automatically. **Every attempt of one call
Expand Down Expand Up @@ -650,10 +749,10 @@ raises, so a handler never has to guard the attribute access itself.
itself, and so on the submit phase of `run()`. A failure raised before the
request exists (a UI-format workflow, an asset that would not upload) or while
`run()` polls the job afterwards carries none. What the key is *good for*
differs, so read it with the surface in mind. `models.run` sends it to a
surface that **replays** a claimed key, which is what makes it a handle on a
generation you were already billed for. `POST /jobs` instead **rejects** a
reused key with `422 idempotency_key_reuse` (see the
differs, so read it with the surface in mind. `models.run` and `models.submit`
send it to a surface that **replays** a claimed key, which is what makes it a
handle on a generation you were already billed for. `POST /jobs` instead
**rejects** a reused key with `422 idempotency_key_reuse` (see the
[`IdempotencyKeyReuse`](#typed-errors) bullet below): keys there are single-use
and there is no replay. So on a `submit()` failure `exc.idempotency_key` is the
key this attempt was made under, not a replay handle. Whether the server ever
Expand Down Expand Up @@ -797,3 +896,13 @@ python scripts/check_drift.py # same check CI runs; fails if committed models
Releases are published to PyPI from a GitHub Release (tag `vX.Y.Z`) by
[`.github/workflows/publish.yml`](.github/workflows/publish.yml), using
PyPI's Trusted Publishing (OIDC) — no API token is stored in this repo.

**Pre-releases** (a release candidate) go out the same way, with one thing to
get right: the workflow validates the tag as **SemVer**, where a pre-release is
a `-`-separated segment. So tag it `v0.2.0-rc1` (or `v0.2.0-rc.1`) — **not**
`v0.2.0rc1`, which is PEP 440's own spelling and is rejected by that check. The
build normalises the accepted form to the PEP 440 version anyway, so
`v0.2.0-rc1` produces `comfy_sdk-0.2.0rc1-py3-none-any.whl`, which `pip install
comfy-sdk` will not pick up without `--pre`. That last part is the point of
shipping one: a pre-release reaches the people who ask for it and nobody
else.
Loading
Loading