feat!: post a model run to Comfy Router with the model's native body - #103
Conversation
`client.models.run` posted `{COMFY_BASE_URL}/api/v2/models/run` with a
`{"model": ..., "arguments": {...}}` envelope. Nothing serves that shape: the
`/api/v2` surface is jobs and assets, and the model-ID-addressed invocation
routes belong to Comfy Router, which `spec/router-openapi.yaml` already
vendors as `POST https://api.comfy.org/v1/models/{provider}/{model}` with the
partner model's own native JSON as the body, forwarded unchanged.
Rebind the transport to that route:
- `COMFY_ROUTER_BASE_URL` (default `https://api.comfy.org`) selects the Router
deployment, separate from `COMFY_BASE_URL` and validated by the same rules.
One variable pointed at both surfaces would send jobs to Router or model runs
to the v2 API.
- `parse_model_id` splits the canonical `{provider}/{model}` id into the two
path segments that address the route, mirroring the TypeScript SDK: exactly
two non-empty segments, no `.`/`..`, a named error for the three-segment
variant form, `TypeError` for a non-string. Each segment is percent-encoded
with `safe=""`.
- `post_model_run` builds its URL absolute against the router base so it does
not pick up `_Prepared.url`'s `/api/v2` prefix, and the same-origin
credential rule now covers both of the client's *configured* origins — both
set at construction, never by a server response, so a server-returned
follow-up link to a third origin still receives no key.
- `models.base_url` and its `repr` report the router base URL, which is the
host these calls actually reach.
The bound path and host are gated against the vendored spec in both
`tests/test_router_spec_contract.py` and `scripts/check_drift.py`, so a Router
sync that moves the route fails CI rather than leaving the SDK posting to a
path the contract no longer declares.
The public method signature is unchanged.
|
Warning Review limit reachedNext included review available in 17 minutes. View limit detailsLimit details: You’ve used the included review currently available. Your 130 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 (18)
Comment |
robinjhuang
left a comment
There was a problem hiding this comment.
Auto-approved under the full-autonomy policy.
Gates verified at b83bfc001a937bf1b20110325a2f90fd3e44da82:
full-autonomylabel present- assigned to, or review requested from, @robinjhuang
- not a draft
- 8 required check(s) green — none failing, none pending
Issued by full-autonomy-approve.yml (run). This approval attests
that the machine gates above passed at this commit. It does not attest that a
human read the diff.
ELI-5
client.models.run("fal-ai/flux-pro", {...})was posting to a URL nothing serves. It sentPOST {COMFY_BASE_URL}/api/v2/models/runwith a Comfy-shaped{"model": ..., "arguments": {...}}envelope — but/api/v2is the jobs-and-assets surface, and running a partner model is Comfy Router's job, on Router's own host. This points the call at the route the vendored contract already declares:POST https://api.comfy.org/v1/models/{provider}/{model}, with the partner model's own native JSON as the body, forwarded to the provider unchanged. The Python you write is unchanged; the request on the wire is what moves.What changed
COMFY_ROUTER_BASE_URL(defaulthttps://api.comfy.org) selects the Router deployment, deliberately separate fromCOMFY_BASE_URLand validated by the same rules (http(s) only, no query/fragment, blank means the default, read per construction, trailing slash stripped).COMFY_ROUTER_BASE_URLandROUTER_BASE_URL_ENV_VARare exported fromcomfy_sdk. One variable pointed at both surfaces would send jobs to Router or model runs to the v2 API, and neither serves the other's routes.comfy_low.transport.parse_model_idsplits the canonical{provider}/{model}id into the two path segments that address the route, mirroring the TypeScript SDK'sparseModelId: exactly two non-empty segments, no./..segment, a message that names the three-segment variant form specifically (rather than "invalid id", which would send a caller hunting for a typo), andTypeErrorrather thanValueErrorfor a non-string. Each segment is percent-encoded withsafe="", so nothing in an id can add a path segment, a query or a fragment._MODEL_RUN_PATH_TEMPLATEreplaces_MODEL_RUN_PATH, verbatim from the spec, andmodel_run_requestreturns the filled path withdict(arguments)unwrapped as the body.post_model_run(sync and async) builds its URL absolute against the router base URL so it does not pick up the/api/v2prefix_Prepared.urladds to a relative path.ComfyLow/AsyncComfyLowtake arouter_base_url=kwarg and exposerouter_base_url/safe_router_base_url.models.base_urland itsreprnow report the router base URL — the host these calls actually reach — rather than the client'sCOMFY_BASE_URL.tests/test_router_spec_contract.py::test_run_path_matches_vendored_specand a third check inscripts/check_drift.py. Both find the path by searching forpost.operationId == "runRouterModel"rather than looking up the path they expect — a lookup would pass vacuously on the one day it matters — and both also compareservers[0].urlagainst the default host.models.runsection states the host, the{provider}/{model}id rule and the native-body contract; a### ChangedCHANGELOG entry marks the breaking wire change.The riskiest line, and why it is safe
_Prepared.headersbroadened its same-origin credential check from one origin to two:That check exists to stop a server-returned absolute follow-up link (
job.urls.self/cancel/events) from carrying the bearer token to an attacker-chosen host. Both origins in the new tuple come from the client's own construction — a module constant, or an environment variable the operator set — and never from a server response, so the set of hosts a malicious server can aim the credential at is unchanged at exactly zero. Without the second origin every real model run against a correctly configured client would401, since Router is a different host from Comfy Cloud by default.tests/test_transport_security.pyasserts both directions: the router origin gets the token when it is notbase_url, and a third origin still gets nothing once the router is trusted.The second-riskiest is the new
ValueErrorin_Prepared.__init__for a non-httprouter_base_url. Without it,url()— which passes a string through only when it starts withhttp— would fall through to thebase_url + /api/v2 + ...branch and send an authenticated run at a mangled URL on the wrong surface.Comfy()never reaches it (the env resolver validates first); it only binds on directComfyLow(...)construction.Verification of the ticket's acceptance criteria
pytestgreenscripts/check_drift.pygreenPOST /v1/models/acme/flux-devwith body{"prompt": ...}and anIdempotency-KeyRouterError(404model_not_foundwithX-Comfy-Request-Id) rather than a bare 404## ResidualThe fake-server run, spelled out rather than left to the suite:
The drift gates were verified to actually fire, not merely to pass: with the vendored path temporarily edited
/v1→/v2(a local edit, reverted),check_drift.pyreportedspec (runRouterModel): /v2/models/{provider}/{model}/sdk (_MODEL_RUN_PATH_TEMPLATE): /v1/models/{provider}/{model}and exited 1, andtest_run_path_matches_vendored_specfailed. That is the exact/v1→/v2sync scenario the gate is for.Negative-claim falsification
This diff adds a capability denial — a three-segment
{provider}/{model}/{variant}id, which the previous code accepted, now raisesValueErrorbefore any request. I went looking for a path on which that capability actually works.It cannot be a regression, and this is the decisive point. For a user to be broken,
models.runwould have had to be reaching Router with a three-segment id. It never sent a path at all: the id travelled in the body of a request to{COMFY_BASE_URL}/api/v2/models/run. So the capability being refused was never functional through this SDK — the refusal is a forward-looking limit on a route being bound for the first time, not the removal of something that worked.Against the contract the SDK is bound to (read out of
spec/router-openapi.yaml, not asserted): it declares exactly three paths —/v1/models,/v1/models/{provider}/{model},/v1/models/{provider}/{model}/openapi.json— none with a third model segment;RouterModelId's pattern is^[a-z0-9]+([._-][a-z0-9]+)*/[a-z0-9]+([._-][a-z0-9]+)*$, exactly two segments, and is documented as "exactly the value that addresses the model onPOST /v1/models/{provider}/{model}"; and a catalog entry'sidis those same two segments joined. Nothing the catalog lists is three-segment.Against the live host: inconclusive, and I can say precisely why.
api.comfy.orgdoes distinguish unknown paths from known ones before auth (POST /v1/definitely/not/a/route→404,POST /v1/models/acme/flux-dev→401), which looked like a usable signal — but the auth boundary turns out to be a prefix rule, not a per-route one:POST /v1/models/a/b/c/d, which no declared route matches, also returns401. So the401on a three-segment path carries no information about whether that path routes. Resolving it needs a credential, which this environment does not have.The dead-end redirects rather than terminates. The
ValueErrornames the working alternative — "that route takes the two-segment{provider}/{model}id. Pass the id the catalog lists" — andGET /v1/modelsis the discovery endpoint that yields those ids.Judgment calls
ROUTER_BASE_URLliteral lives incomfy_low.transport, andcomfy_sdk.client.COMFY_ROUTER_BASE_URLis bound to it rather than being a second literal. The transport owns the wire and needs a default for itsrouter_base_url=kwarg; two independent literals for one host is precisely the drift the new gate exists to catch._resolve_base_urlwas factored onto a shared_resolve_env_url(var, default)so the two targets cannot validate differently. The v2 path's behaviour — including its exact error message — is byte-identical; only the variable name in the message varies, which is the point.retry.pyand a retry-test comment each saidPOST /models/run"is in neither vendored spec". It is inspec/router-openapi.yaml, which also states thedeadline_exceededcollect rule the default retry policy is built on. The substance of the caveat survives — a deploymentCOMFY_ROUTER_BASE_URLnames may apply the v2 rule instead — but the premise had to be fixed.tests/test_sync_async_parity.py's_default_deploymentnow pops both variables. It exists so a stray export in a developer's shell cannot turn the module into a collection error, and_resolve_router_base_urlraises the same way_resolve_base_urldoes; leaving it popping one would have reopened that hole.Residual
Comfy().models.run("not-a/real-model", {})against the real host raise aRouterErrorsubclass — a404model_not_foundcarryingX-Comfy-Request-Id— proving the request reached a Router handler. No API key is available in this environment, so the typed-404 half is unverified. What was verified, unauthenticated and non-mutating:POST https://api.comfy.org/v1/models/not-a/real-modelreturns401with anX-Comfy-Request-Id(eb3cb6e0-…), so a Comfy Router handler on the right host did answer the URL this PR now builds; auth is simply checked before model lookup. A reviewer with a key should run the ticket's exact command and confirm the error is a typedRouterErrorrather than a bare404. Related and also unverified: whether the401body shape ({"message": ...}, noX-Comfy-Error-Type) — which differs from theRouterRequestError{detail, error_type}shape the spec declares — maps to the typed hierarchy correctly, or falls through to an untyped error. That is an edge-gateway response rather than Router's own, and it is worth its own look.{provider}/{model}/{variant}id is addressable on the live route is unresolved. The vendored contract says no (see the falsification section) and this PR refuses it locally on that basis, which is what the ticket specifies. The live host cannot answer it without a credential, because it auth-gates the whole/v1/models*prefix. If a variant id turns out to be addressable, the fix is to relaxparse_model_idand add the third segment to the path template — the refusal is one function and one constant.src/sdk/credentials.tsandsrc/sdk/models.ts(runUrl/parseModelId) as the behaviour this mirrors. That repo is not checked out in this environment, so the parity claim rests on the ticket's description of it plus the shared vendored contract, not on a diff of the two implementations. Worth a read by someone with both repos open, specifically on the id-rejection rules: an id one SDK accepts and the other refuses is exactly the cross-SDK divergence this was meant to close.tests/integration/test_gateway_e2e.pyis opt-in and points at a live v2 deployment; it neither exercisesmodels.runnor readsCOMFY_ROUTER_BASE_URL. All coverage here is against the stub intests/conftest.py.COMFY_BASE_URLat a Router host to make model runs work must move that value toCOMFY_ROUTER_BASE_URL. Flagged in the CHANGELOG as breaking. Nothing in the SDK detects or warns about that configuration; a stale setup will send jobs to Router and get route errors rather than a message naming the variable.Provenance
pytest: 620 passed, 4 skipped, 0 failed.ruff check .: all checks passed.ruff format --check .: 51 files already formatted.mypy src: no issues in 19 source files.scripts/check_drift.py: 3/3 checks OK, exit 0 (and verified to exit 1 on a simulated/v1→/v2spec move).scripts/check_public_repo_hygiene.py: no internal-only references. Live unauthenticated probes ofapi.comfy.orgas described above.RouterErroron a 404) was not exercised — no API key in this environment; see## Residual.