feat: add models.run on both the sync and async clients - #73
Merged
Conversation
`client.models.run(model, arguments)` returns the completed generation in one call. The awaitable form is `AsyncComfy`, not a `run_async()` suffix: one operation, one name, and `await` is what makes it asynchronous. A test asserts the suffix's absence on both clients and both transports so it cannot be reintroduced quietly. The server awaits completion on its side — polling the upstream provider inside the call where that provider is submit/poll — so the client contract is a single request whose body is the finished result. That result is the provider's native payload, handed back as decoded JSON with no wrapper class over it. Two consequences are plumbed here: runs default to a 10-minute timeout (`MODEL_RUN_TIMEOUT`) instead of the client's 30s, which is sized for ordinary API calls and would abort a healthy generation; and every run sends an `Idempotency-Key`, minted per call unless the caller supplies one. The route is not in the vendored contract yet, so the binding is hand-written, kept out of `OPERATION_IDS`, and confined to `model_run_request` / `_MODEL_RUN_PATH`.
|
Warning Review limit reachedNext included review available in 29 minutes. View limit detailsLimit details: You’ve used the included review currently available. Your 108 included PR review attempts over the past 7 days set your current allowance at 1 review per hour. Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (6)
Comment |
mattmillerai
marked this pull request as ready for review
August 23, 2026 22:59
robinjhuang
approved these changes
Aug 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ELI-5
You give the client a model name and a bag of arguments; you get the finished picture (or video, or whatever the model makes) back from that one call. Nothing to poll, nothing to wait on yourself. If you're writing async code, you use the async client and put
awaitin front of it — that's the whole difference. There is no second, differently-named method for the async case, and there never will be.What changed
client.models.run(model, arguments)on bothComfyandAsyncComfy(src/comfy_sdk/models.py). One call, returns the result of a completed generation. The sync one blocks; the async one is awaited and returns the same shape.run_async()exists on either client, either namespace, or either transport — and that is asserted by tests over all six classes rather than left to absence, plus a broader guard that no public method on those classes may encode sync-vs-async in its name (^async_/_async$/_sync$).acloseis the one deliberate rename and does not match; the existing parity test already covers it.MODEL_RUN_TIMEOUT(src/comfy_low/transport.py), 10 minutes read/write/pool with a 10s connect. The server holds the connection until the generation is complete, polling the upstream provider itself where the provider is submit/poll, so the client's own 30s default would abort a perfectly healthy run.connectstays short: an unreachable host is not a slow generation. Callers can pass seconds, anhttpx.Timeout, orNoneto wait indefinitely.Idempotency-Keyplumbed onto every run. A fresh key is minted per call unlessidempotency_key=is supplied, matching whatsubmit()already does. The value semantics and the retry interaction are deliberately not decided here.post_model_runonComfyLowandAsyncComfyLow, returning the decoded body verbatim.tests/test_models_run.py(35 tests) against the stubbed server, plus aPOST /api/v2/models/runroute intests/conftest.py.### models.runsubsection in the README's models-namespace section, a CHANGELOG[Unreleased]entry, and module/method docstrings.The one real judgment call: the wire contract is not published yet
spec/openapi.yaml(the vendored, one-way-synced v2 contract,spec/VERSION2.0.0) declares 11 operationIds, 0 of which are model routes — the same measurement the namespace PR reported, unchanged since. So this method could not be typed against or generated from a committed contract, and the request shape had to be chosen rather than read. What I chose, and why:POST /api/v2/models/runwith{"model": ..., "arguments": {...}}in the body, rather than the model id in the path. Model ids are commonly provider-namespaced and contain/(vendor/family/variant); in a path segment that needs percent-encoding which intermediaries normalize inconsistently. A named body field also matches how the rest of this API shapes request bodies ({"workflow": ...}on job submission) and leaves room for sibling fields without moving the route._MODEL_RUN_PATHandmodel_run_request()insrc/comfy_low/transport.py. Reconciling it when the real route is vendored is a change to those two things; no caller-facing signature, no test outside the request-shape assertions, and no other transport method depends on it.OPERATION_IDS. The spec-coverage test asserts that set equals the spec's exactly, so a non-spec binding must not be registered there. Both the module docstring and the constant say plainly that this one binding is not backed by anoperationIdand why.If the router's published route differs, that is a one-place fix — but it is a genuine unknown and I would not merge this ahead of the server-side contract landing without someone confirming the shape.
Other judgment calls
argumentsis a required positional parameter, not one defaulting to{}. Adding a default later is backwards compatible; removing one is not, and the ticket's own framing is that a published signature cannot be withdrawn. A model that takes nothing gets{}.200or201; anything else raises. A202 Acceptedwould mean the server did not await completion, which contradicts the contract this method implements — returning an unfinished payload as if it were a result would be worse than raising. If the router really does answer 202 for some class of model, that is a contract question, not a client patch.Comfy.submit/Comfy.runretry a 429 carryingRetry-After;models.rundoes not, so aqueue_fullsurfaces immediately asQueueFull. That asymmetry is deliberate — retry policy is explicitly separate work — but it is the thing a reviewer is most likely to read as an omission, so: it is one.translating()helper, so a protocolApiErrornever reaches an integrator (the rule PR fix: keep protocol errors off the public surface, add AsyncOutput.to_stream, widen the 429 retry #63 established). No new exception types were added; that taxonomy is separate work. An unmapped code lands asComfyErrorwith itscodeandhttp_statusintact, which a test asserts.dict[str, Any]— the payload contract is a JSON object. A hypothetical top-level JSON array would pass through unvalidated (the annotation would then be imprecise). I did not add a shape guard: the promise is to hand back the provider's payload untouched, and rejecting a valid-but-unexpected body would be inventing a failure the server has not specified.cast()rather than restructuring_ModelsBase._lowis typed as the sync-or-async union on the shared base (narrowing it per subclass is a mypy override error, as the namespace PR noted). Eachruncasts locally instead. Making_ModelsBasegeneric would remove the casts, but it is a type-level refactor of just-merged code and outside what this change needs.client.run(workflow)andclient.models.run(model, arguments)now sit side by side and are different operations — one runs a workflow graph, the other runs a hosted model. I leftclient.runcompletely alone: renaming a published method is breaking and is nobody's call to make in this diff. Worth a docs pass if the collision reads badly in practice.BrokenPipeError/ConnectionResetErrorinhandle_one_request. The timeout tests deliberately abort a slow request, which previously printed a traceback into the test output. Only those two exception types are caught, and neither could ever fail a test before (they were logged, not raised into the test), so nothing else changes.Negative-claim falsification
This diff adds a capability; it denies none. There is no "not supported" branch, no stub that raises, no test flipped to assert a dead-end. Two strings in the diff would trip a mechanical denial scan, and both are in the same test:
model_unavailableappears as a server-sent error code in the stub, used to prove the SDK passes an unmapped code through with itscodeandhttp_statusintact instead of swallowing or renaming it. Nothing insrc/ever produces that message. The positive capability is exercised directly and passes on both clients (runreturns a completed result, sync and async, against the stub). The only raises this change can produce are (i) pass-through of an error envelope the server sent and (ii) anhttpxtimeout when the caller explicitly configures one shorter than the run — which has its own control test proving the default is what prevents it.Sizing the part not covered
A sweep of every class defined in
comfy_sdkandcomfy_low— 639 public attributes across both packages — found 0 existing names matching the suffixed-async pattern this change now guards against, so the guard codifies the current state rather than cleaning up after it. On the contract side, the 11 declared operationIds, 0 of them model routes above is the measurement of what is still unspecified.Not exercised here
The specification, the design decision record, and the tracker issues that settle this method's signature and its one-async-mechanism rule live in internal tools that are not reachable from the environment this was built in; the requirements were taken as given rather than re-read at the source. The related server-side story that makes a submit/poll provider resolve inside a single call is likewise named but unread — this change depends on its contract (the server does not answer until the run is complete), which is exactly the assumption the timeout default and the tests encode, but I could not read that story to confirm it. There is no live router endpoint to exercise from here, so nothing in this change has been run against a real model run: verification is the stubbed server plus the full local gate set, below.
Provenance
pytest215 passed / 4 skipped (was 180/4 on the base — 35 new);mypy srcno issues in 17 files;ruff check .clean;ruff format --check .clean (45 files);scripts/check_drift.pymodels in sync with the spec;scripts/check_public_repo_hygiene.pyclean