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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ the fuller account of each version, including verification notes.

### Added

- `models.list()` and `models.schema()`, so you can discover Comfy Router models from Python
as the TypeScript SDK already can. `list(cursor=, limit=, timeout=)` returns an iterable that
walks the catalog (`GET /v2/models`), following `next_cursor` while `has_more` is true, and
yields `CatalogModel` entries (`id`, `provider`, `model`, `billing`). `list(...).page()`
returns one `ModelPage` (`data`, `has_more`, `next_cursor`, `limit`, `request_id`).
`schema(model, etag=, timeout=)` reads `GET /v2/models/{provider}/{model}/openapi.json` into a
`SchemaResult`. With `etag=`, it sends `If-None-Match`, and a `304` returns `unchanged=True`
with `document=None` rather than raising. Both methods use the Router host and the client's
credential, raise the same typed Router exceptions as `models.run`, retry under the client's
policy (a keyless read also retries a `5xx` or read timeout whenever that policy retries at
all), and default to a 30-second timeout. `AsyncComfy` has the same methods
(`async for ... in client.models.list()`, `await client.models.schema(...)`).
- `RouterRunResult.credits_used` — what Comfy Router reported a run cost, lifted from the
`X-Comfy-Credits-Used` response header onto what `models.run_detailed()` returns. It is a
price rather than a settled ledger entry, absent means "not reported" and never "free", and
Expand Down
87 changes: 81 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,9 @@ two variables.
`base_url` and `timeout` are a read-only view of that configuration; model
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.
the same request. Two more read-only calls tell you what to run before you run
it: `list`, the model catalog, and `schema`, one model's input and output
schemas.

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

Expand Down Expand Up @@ -484,9 +486,10 @@ Path("hello.mp3").write_bytes(result.content)
```

`BinaryResult` is importable from `comfy_sdk` for exactly this `isinstance`
check. Which shape a given model returns is in its own contract — `GET
/v2/models/{provider}/{model}/openapi.json`, whose `200` is `application/json`
for a JSON model and `*/*` with `format: binary` for a bytes one.
check. Which shape a given model returns is in its own contract —
[`models.schema`](#modelsschema--what-a-model-takes-and-what-it-returns)
fetches it — whose `200` is `application/json` for a JSON model and `*/*` with
`format: binary` for a bytes one.

Note `content_type` keeps the header's **parameters**, because for some media
types the parameters are part of what the bytes are — ElevenLabs' `pcm_*` output
Expand All @@ -506,6 +509,76 @@ contract marks that header required on every answer it sends, so an HTML
interstitial from a proxy in front of it has none), and `not result.content`
means nothing was delivered. Check them before writing `content` to disk.

### `models.list` — what you can run

```python
from comfy_sdk import Comfy

with Comfy(api_key="comfyui-...") as client:
for model in client.models.list():
print(model.id, model.billing)
```

That walks Router's model catalog — `GET https://api.comfy.org/v2/models` —
page by page, following `next_cursor` while `has_more` is true, and yields one
`CatalogModel` per entry: `id` (the `{provider}/{model}` id `models.run` takes),
`provider`, `model`, and `billing` (per-model billing facts such as
`charges_on_policy_rejection`, never prices). Nothing is fetched until you
iterate, and each loop is a fresh walk.

For one page and its paging facts instead, call `.page()`:

```python
page = client.models.list(limit=50).page()
page.data # tuple of CatalogModel
page.has_more # walk on this, not on a short page
page.next_cursor # pass back as list(cursor=...) for the next page
page.limit # the page size the server actually served
page.request_id # X-Comfy-Request-Id, for a support request
```

`limit` is sent as given. The server defaults to 20 and clamps anything above
100 down to 100 rather than rejecting it, so read `page.limit` for the size you
got. The cursor is opaque and only good for the walk that produced it. On
`AsyncComfy` it is `async for model in client.models.list():` and
`await client.models.list().page()`.

### `models.schema` — what a model takes, and what it returns

```python
with Comfy(api_key="comfyui-...") as client:
result = client.models.schema("bfl/flux-2-pro")
document = result.document # the model's own OpenAPI document
etag = result.etag # keep it for the next call
```

That is `GET https://api.comfy.org/v2/models/bfl/flux-2-pro/openapi.json`: the
model's input schema (the body `models.run` sends) and its output schema, as a
standalone OpenAPI document. The id is validated exactly as `models.run`
validates it, before any request.

Pass a tag you stored earlier to make the read conditional. When the document
has not changed, the server answers `304` with no body, and you get
`unchanged=True` back rather than an exception:

```python
result = client.models.schema("bfl/flux-2-pro", etag=etag)
if result.unchanged:
... # your cached document is still current; result.document is None
else:
document, etag = result.document, result.etag
```

The SDK keeps no cache, so storing the tag and the document is up to you. An
unknown model raises `ModelNotFound`, and `await client.models.schema(...)` is the
async form.

Both methods use the same host and credential as `models.run`, raise the same
typed Router exceptions (see
[Catching Comfy Router errors](#catching-comfy-router-errors)), and default to
a 30-second timeout. Pass `timeout=` seconds, an `httpx.Timeout`, or `None` to
wait indefinitely.

### Image to image — upload an asset first

An image-to-image model takes an image *as input*, and Router forwards the
Expand Down Expand Up @@ -558,8 +631,10 @@ result = client.models.run(
)
```

Which form a model takes is in its input schema — `GET
/v2/models/{provider}/{model}/openapi.json`, or the model's page in the
Which form a model takes is in its input schema —
`client.models.schema("bfl/flux-2-pro").document` (see
[`models.schema`](#modelsschema--what-a-model-takes-and-what-it-returns)), or
the model's page in the
[Router model catalog](https://docs.comfy.org/development/comfy-router/models).

Because the server may legitimately hold the connection for minutes, `run` uses
Expand Down
161 changes: 161 additions & 0 deletions src/comfy_low/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@
for exactly the reason the run path was, and they are the one part of this
change a spec sync is expected to correct.

``get_model_catalog`` / ``get_model_schema`` bind the two discovery reads of
the same contract (``listRouterModels``, ``getRouterModelInputSchema``), and
follow the run path's rule: their routes live in :data:`_MODEL_CATALOG_PATH` /
:data:`_MODEL_SCHEMA_PATH_TEMPLATE` alone, pinned against the vendored file.

This layer contains no orchestration, retries, hashing, or reconnection — those
live in ``comfy_sdk``.
"""
Expand Down Expand Up @@ -125,6 +130,21 @@
_MODEL_REQUEST_STATUS_PATH_TEMPLATE = _MODEL_REQUEST_PATH_TEMPLATE + "/status"
_MODEL_REQUEST_CANCEL_PATH_TEMPLATE = _MODEL_REQUEST_PATH_TEMPLATE + "/cancel"

#: Routes for model *discovery* — the catalog (``operationId:
#: listRouterModels``) and one model's input/output schema document
#: (``operationId: getRouterModelInputSchema``), verbatim from
#: ``spec/router-openapi.yaml``. Pinned against the vendored file by
#: ``tests/test_router_spec_contract.py`` exactly as
#: :data:`_MODEL_RUN_PATH_TEMPLATE` is, so a sync that moves either route fails.
_MODEL_CATALOG_PATH = "/v2/models"
_MODEL_SCHEMA_PATH_TEMPLATE = _MODEL_RUN_PATH_TEMPLATE + "/openapi.json"

#: Default timeout for a discovery read. Both routes answer from a catalog the
#: server already holds, so nothing here waits on a generation — the bound is
#: the client's ordinary 30s, stated here so it does not silently follow a
#: client configured with a much longer (or shorter) timeout for other calls.
DISCOVERY_TIMEOUT = httpx.Timeout(30.0)

#: Longest request id accepted into a path. The contract mints UUIDs (36
#: characters); the bound exists so a server-controlled value that is NOT one
#: cannot reach the public handle, a log line or an exception message unbounded.
Expand Down Expand Up @@ -435,6 +455,80 @@ def model_request_path(model: str, request_id: str, template: str) -> str:
)


def model_catalog_path(cursor: str | None = None, limit: int | None = None) -> str:
"""Sans-IO path (with query) for one page of the Router model catalog.

Each parameter is sent only when given, so a bare call is the first page at
the server's default size. ``limit`` is passed through unchanged, including
a value above the declared maximum of 100: the route clamps rather than
rejects it and echoes the size it actually served, so refusing it here would
only disagree with the server.
"""
params: list[tuple[str, str]] = []
if cursor is not None:
params.append(("cursor", cursor))
if limit is not None:
params.append(("limit", str(limit)))
query = urlencode(params)
return f"{_MODEL_CATALOG_PATH}?{query}" if query else _MODEL_CATALOG_PATH


def model_schema_path(model: str) -> str:
"""Sans-IO path for one model's input/output schema document.

Addressed by the same ``{provider}/{model}`` id, validated and
percent-encoded exactly as :func:`model_run_request` does it, so an id that
runs is an id whose schema can be read.
"""
provider, name = parse_model_id(model)
return _MODEL_SCHEMA_PATH_TEMPLATE.format(
provider=quote(provider, safe=""), model=quote(name, safe="")
)


def model_schema_headers(etag: str | None) -> dict[str, str] | None:
"""``If-None-Match`` for a conditional schema read, or ``None`` for a plain one.

Raises ``ValueError`` before any request for an empty or non-ASCII tag: an
empty header makes the read effectively unconditional, so a ``304`` to it
could not honestly mean "your copy is current", and a non-ASCII value would
fail inside httpx's header encoding as an untyped ``UnicodeEncodeError``.
"""
if etag is None:
return None
if not isinstance(etag, str):
raise TypeError(f"etag must be a str, got {type(etag).__name__}")
if not etag or not etag.isascii():
raise ValueError(f"etag must be a non-empty ASCII string; got {etag!r}")
return {"If-None-Match": etag}


def model_schema_answer(
p: _Prepared, resp: httpx.Response, etag: str | None
) -> dict[str, Any] | None:
"""The schema document, or ``None`` for a ``304`` to a conditional read.

``None`` is reserved for the ``304`` so the SDK can read it as "unchanged":
a ``200`` whose body decodes to anything but a JSON object (``null``, a list,
a scalar) is raised as ``invalid_response`` rather than passed through.
"""
# Only an answer to a conditional read: a 304 to a request that sent no tag
# cannot mean "your copy is current", so it falls through and is raised
# like any other unexpected status.
if resp.status_code == 304 and etag is not None:
return None
body: Any = p.parse_or_raise(resp, (200,))
if not isinstance(body, dict):
raise ApiError(
f"The {resp.status_code} schema response is not a JSON object",
code="invalid_response",
http_status=resp.status_code,
request_id=_request_id(resp),
body_excerpt=_body_excerpt(resp),
)
return body


def _build_user_agent(client_info: str | None) -> str:
"""SDK identity sent on every request. This is request metadata (not
telemetry — no phone-home), so adoption is measurable server-side from
Expand Down Expand Up @@ -1326,6 +1420,47 @@ def put_model_request_cancel(
resp = self.raw_request("PUT", url, timeout=timeout)
return self._p.parse_or_raise(resp, (200, 202, 204)), resp.headers

# -- models: discovery ------------------------------------------------
def get_model_catalog(
self,
*,
cursor: str | None = None,
limit: int | None = None,
timeout: Any = DISCOVERY_TIMEOUT,
) -> tuple[dict[str, Any], httpx.Headers]:
"""GET ``{router_base_url}/v2/models`` — one page of the model catalog.

``listRouterModels`` of ``spec/router-openapi.yaml``, hand-bound — see
:data:`_MODEL_CATALOG_PATH`. Returns ``(body, headers)`` so the caller
can read ``X-Comfy-Request-Id`` off a success.
"""
url = self._p.router_base_url + model_catalog_path(cursor, limit)
resp = self.raw_request("GET", url, timeout=timeout)
return self._p.parse_or_raise(resp, (200,)), resp.headers

def get_model_schema(
self,
model: str,
*,
etag: str | None = None,
timeout: Any = DISCOVERY_TIMEOUT,
) -> tuple[dict[str, Any] | None, httpx.Headers]:
"""GET ``{router_base_url}/v2/models/{provider}/{model}/openapi.json``.

``getRouterModelInputSchema`` of ``spec/router-openapi.yaml``. With
``etag`` the request carries ``If-None-Match``, and a ``304`` — the
document is unchanged — is a success, returned as a ``None`` body
rather than raised: it is the answer the caller asked for.

Raises ``TypeError``/``ValueError`` before any request when ``model``
is not a ``{provider}/{model}`` id (:func:`parse_model_id`) or ``etag``
is not a non-empty ASCII string (:func:`model_schema_headers`).
"""
url = self._p.router_base_url + model_schema_path(model)
headers = model_schema_headers(etag)
resp = self.raw_request("GET", url, headers=headers, timeout=timeout)
return model_schema_answer(self._p, resp, etag), resp.headers


class AsyncComfyLow:
"""Asynchronous protocol bindings — mirrors :class:`ComfyLow`."""
Expand Down Expand Up @@ -1696,6 +1831,32 @@ async def put_model_request_cancel(
resp = await self.raw_request("PUT", url, timeout=timeout)
return self._p.parse_or_raise(resp, (200, 202, 204)), resp.headers

# -- models: discovery ------------------------------------------------
async def get_model_catalog(
self,
*,
cursor: str | None = None,
limit: int | None = None,
timeout: Any = DISCOVERY_TIMEOUT,
) -> tuple[dict[str, Any], httpx.Headers]:
"""Async :meth:`ComfyLow.get_model_catalog`."""
url = self._p.router_base_url + model_catalog_path(cursor, limit)
resp = await self.raw_request("GET", url, timeout=timeout)
return self._p.parse_or_raise(resp, (200,)), resp.headers

async def get_model_schema(
self,
model: str,
*,
etag: str | None = None,
timeout: Any = DISCOVERY_TIMEOUT,
) -> tuple[dict[str, Any] | None, httpx.Headers]:
"""Async :meth:`ComfyLow.get_model_schema` — a ``304`` is a ``None`` body."""
url = self._p.router_base_url + model_schema_path(model)
headers = model_schema_headers(etag)
resp = await self.raw_request("GET", url, headers=headers, timeout=timeout)
return model_schema_answer(self._p, resp, etag), resp.headers


def _looks_like_path(s: str) -> bool:
return s.startswith("http") or s.startswith("/")
Expand Down
7 changes: 7 additions & 0 deletions src/comfy_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
except PackageNotFoundError: # running from a source tree, not installed
__version__ = "0+unknown"

from .model_catalog import AsyncModelList, CatalogModel, ModelList, ModelPage, SchemaResult
from .models import RouterRunResult

__all__ = [
Expand All @@ -96,6 +97,12 @@
"AsyncComfy",
# model runs
"RouterRunResult",
# model discovery
"CatalogModel",
"ModelList",
"AsyncModelList",
"ModelPage",
"SchemaResult",
# assets / workflows / jobs / outputs
"Asset",
"AsyncAsset",
Expand Down
Loading
Loading