From e43c1c48b5cac7874940a0319dcab82d87ddc80f Mon Sep 17 00:00:00 2001 From: mattmillerai <7741082+mattmillerai@users.noreply.github.com> Date: Tue, 25 Aug 2026 05:35:12 +0000 Subject: [PATCH] chore: sync Comfy API v2 specification and Comfy Router reference from cloud@d5155ac --- comfy-router-limitations.mdx | 106 +++++++++++++ comfy-router-quickstart.mdx | 291 ++++++++++++++++++++++++++++++++++ comfy-router-reference.mdx | 299 +++++++++++++++++++++++++++++++++++ openapi-v2.yaml | 24 ++- 4 files changed, 719 insertions(+), 1 deletion(-) create mode 100644 comfy-router-limitations.mdx create mode 100644 comfy-router-quickstart.mdx create mode 100644 comfy-router-reference.mdx diff --git a/comfy-router-limitations.mdx b/comfy-router-limitations.mdx new file mode 100644 index 000000000..659fe7caf --- /dev/null +++ b/comfy-router-limitations.mdx @@ -0,0 +1,106 @@ +--- +title: "Comfy Router limitations" +description: "What Comfy Router does not do today, what to use instead where an alternative exists, and which of those limits are expected to change." +--- + + +**Comfy Router is not generally available yet.** The routes referenced below — +`POST /v1/models/{provider}/{model}` and its catalog and schema siblings — are +not serving requests yet: an authenticated call answers `404` today. This page +describes the contract they will serve, published ahead of that rollout so an +integration can be written against a known shape. Everything below is a +statement about that contract, not about behaviour you can exercise right now. + + +Comfy Router is one synchronous call: you send a partner model's native input to one host with one credential, the connection stays open, and a `200` carries that model's native output. That shape is what makes the first integration short, and it is also where every limit on this page comes from. Read this page before you design around Router, not after — most of what follows has a straightforward alternative, and the ones that do not are worth knowing before you build on an assumption Router does not hold. + +## At a glance + +Each row links to the section that explains it. **Deliberate** means the limit is part of how Router works and is not waiting on anything; **not yet** means Router is expected to gain the capability, though this page makes no commitment about when. + +| Limitation | Use instead | Status | +| --- | --- | --- | +| [No queued submission — the call is synchronous](#no-queued-submission) | Hold the connection open, or use a partner-proxy route that submits and polls | Not yet | +| [No cost or credit figures on a response](#no-cost-or-credit-figures-on-a-response) | Read your balance and usage on the Comfy platform; check `billing` on the model's catalog entry before calling | Not yet | +| [No way to resume a call you lost](#no-way-to-resume-a-call-you-lost) | Send `Idempotency-Key`, so a retry is charged at most once — it does not recover the lost result | Not yet | +| [Calls are cut off at a server deadline](#calls-are-cut-off-at-a-server-deadline) | Give your client a timeout above the deadline; split work that cannot finish inside it | Deliberate | +| [No progress while a call runs](#no-progress-while-a-call-runs) | Nothing on Router today; a partner-proxy route may expose its own progress | Not yet | +| [Three forecast buckets are not in the vocabulary](#three-forecast-buckets-are-not-in-the-vocabulary) | Handle the fourteen buckets Router publishes; treat anything unrecognized as `internal_error` | Not yet | +| [Router does not cover every partner operation](#router-does-not-cover-every-partner-operation) | The partner-proxy routes under `/proxy/…` on the same host | Deliberate | + +## No queued submission + +There is one way to run a model: `POST /v1/models/{provider}/{model}`, which holds the connection until the generation finishes and returns the result in the response. There is no endpoint that accepts a job, hands you an identifier and lets you collect the result later, and no callback or webhook on completion. A queued counterpart is planned and is referenced in the API reference as `/v1/queue/models/{provider}/{model}`; it is not part of the contract today, and a call to it is not served. + +**What to do instead.** For most models this is a non-issue: keep the connection open and read the result. A fast image model returns in a few seconds; a long video generation can run for minutes, and Router will hold the connection for it. Set a generous client read timeout — above [Router's own deadline](#calls-are-cut-off-at-a-server-deadline) — and treat the call as long-running rather than as a fast request. If your architecture genuinely cannot hold a connection open — a serverless function with a short execution ceiling, a browser tab you expect the user to close — then run the call from a worker you control that can, or use a partner-proxy route for a provider that exposes its own submit-and-poll pair. See [the last section](#router-does-not-cover-every-partner-operation). + +**Status: not yet.** The queued path is expected; nothing on this page commits to when. + +## No cost or credit figures on a response + +A Router response tells you what the model produced, and its contract says nothing about what it cost. There is no charge amount, no credit balance and no usage figure in the body, and the route declares no cost header. One caveat, so it does not surprise you: Router shares a billing path with the partner-proxy routes, and that path stamps `X-Comfy-Credits-Used` on a billed response for an allowlist of providers, so the header can appear on a Router call to one of them. It is not part of Router's contract — it is absent for every provider outside that allowlist, and it is deliberately *not* replayed on an idempotent retry, precisely so a client summing it cannot double-count a call that was only paid for once. Do not build reconciliation on it. The model catalog is the same: it carries billing *facts* a caller needs before invoking, never prices. So you cannot reconcile spend from a Router response alone, and you cannot show a user "this call cost X" without getting X from somewhere else. + +**What to do instead.** Your balance, your usage and your invoices live on the Comfy platform at [platform.comfy.org](https://platform.comfy.org) — that is the source of truth for what you have spent and what you have left, and it is unaffected by anything on this page. Two things Router does tell you at call time are worth using: a call refused for lack of credit comes back as `insufficient_credits`, so you can handle exhaustion as a typed error rather than by pre-checking a balance; and each model's catalog entry carries `billing.charges_on_policy_rejection`, which says whether that specific model charges you for a generation it then refuses on content-policy grounds. It is a **string with three values**, not a boolean: `yes`, `no` and `unknown`. Read `unknown` as "this might charge you" — it means nobody has established that model's behaviour yet, and it exists precisely so an unchecked model is not published as a `no`, which is a claim. The field is deliberately not an `enum`, so treat any value you do not recognize as `unknown` too, and do not write a truthiness check over it: the string `"no"` is truthy in most languages, and that check gets backwards the one case it exists to catch. Providers differ on that, the difference is invisible at call time, and reading it before you call is how you avoid a charge you cannot explain afterwards. + +**Status: not yet** for per-call figures. Note that the *catalog* deliberately carries no prices — pricing belongs where pricing is maintained, not duplicated into a model listing that would drift from it. + +## No way to resume a call you lost + +Router does not keep a resumable record of an in-flight call. There is no status route, no job identifier, and nothing to reconnect to: if the connection drops mid-call — a client crash, a network partition, a deploy that restarts your process — the response is gone, and the call is not something you can ask about afterwards. Whether the *generation* completed and was charged is a separate question from whether you received it, and losing the connection does not reliably answer either. + +**What to do instead.** Send an `Idempotency-Key` header on every call. It does not make a lost call resumable, but it makes retrying one safe. Router reserves the key for the duration of the call, and when the call actually reached you with an answer it records that response against the key for 24 hours; retrying with the **same** key then replays the recorded response instead of dispatching — and re-charging — the provider a second time, marked `Idempotent-Replayed: true` so you can tell a replay from a fresh run. Generate a fresh key per logical call, not per attempt; the same key presented with a *different* request body is a `409` rather than a silent overwrite. + +Be precise about what that buys you, because it is a **billing** property and not a delivery one: **a key is charged at most once.** It is not a promise that a key is dispatched to the provider at most once. Router holds a key against an answer you actually received; the outcomes that charged you nothing release it so the call can be made again. A `5xx`, a `408`/`425`/`429`, and — this is the one that matters here — a call where nothing reached you at all: each of those releases the key, and a retry with it genuinely re-runs and re-dispatches the provider. + +**So a dropped connection is the case idempotency does *not* rescue.** A connection lost mid-call usually means no response was ever committed to you, which is exactly the release path above: retrying with the same key starts a fresh run rather than handing you the result you missed, and if the original generation had already been dispatched the provider may run it a second time. That is the right default — an unbilled call you never received should be re-runnable — but plan for "retry produces a new run", not "retry collects the lost one". + +When Router *does* hold something for the key, the retry is answered rather than re-run: either the original response replayed, or a `409` explaining why it cannot be. A retry sent while the original is still in flight is a `409` carrying `Retry-After`, so wait and re-send the same key. A retry against a call that completed but whose response Router could not keep a faithful copy of is also a `409` — and that is not only the oversized-response case: a response past the replay cap, a handler that failed or panicked after answering, and a write to you that failed or came up short all record the key as consumed-but-not-replayable and return the same `409`. Do not go hunting for a size problem when you see it. The guidance in every one of those cases is the same: use a **new** key. The original completed and was charged, and Router will neither invent its response nor re-run it under the old key. + + +**Not yet in the generated contract.** The `Idempotency-Key` request header, the `409` response and the `Idempotent-Replayed` and `Retry-After` response headers described here are not declared on `POST /v1/models/{provider}/{model}` in the OpenAPI contract the reference is generated from, so they do not appear in the generated API reference and the SDKs do not model them. Send and read them yourself until they do. + + +**Status: not yet.** Durable, resumable execution is expected to arrive with the queued path, which is where a request record has somewhere to live. Idempotent retry is the answer today and is not a stopgap — it is worth wiring in regardless. + +## Calls are cut off at a server deadline + +One Router call may hold its connection for **10 minutes**. That is the default; it is a server-side configuration value rather than a fixed constant, so treat it as the number to design against rather than a guarantee etched into the contract. Past it, Router stops waiting, cancels its own in-flight request to the provider and answers `504` with `X-Comfy-Error-Type: deadline_exceeded`. **A `deadline_exceeded` call is not billed** — the bound is ours, so its cost is ours. + +Two things that cancellation does not do, both worth knowing before you retry. It does not recall a generation a provider has already accepted: for the partners Router drives by submitting a job and polling it, expiring the deadline ends Router's own wait, not the provider's work, so that job can run to completion and a retry can produce a **second generation** (you are still not billed for the timed-out call). And it cannot un-send an answer: if the handler wins the race and commits a response just as the bound expires, you keep that response rather than the `504`. + +Do not confuse it with the other `504`. `provider_timeout` is the partner failing to answer in time, and that one **is** billed; `deadline_exceeded` is Router's own bound expiring. Two causes, two billing outcomes, which is exactly why they are two buckets on the same status code — branch on `X-Comfy-Error-Type`, never on the status alone. + +**What to do instead.** Set your client's read timeout comfortably *above* the deadline, not below it. A client that gives up first turns a typed `504` with a request identifier into an opaque local abort, and you lose the one artifact support can trace. If a single generation genuinely cannot finish inside the deadline, Router is not the right shape for it today: run it through a partner-proxy route that submits and polls, or break the work into calls that each finish inside the bound. + +**Status: deliberate.** A bound has to exist — without one, a stuck upstream holds a connection and a concurrency slot indefinitely. The specific number may be tuned; the existence of a deadline will not go away. + +## No progress while a call runs + +`POST /v1/models/{provider}/{model}` returns exactly once, at the end. There is no streaming response, no server-sent events, no percentage, no partial or preview frame. This holds even for partners whose own API is submit-and-poll: Router does that polling internally, inside your one call, and the intermediate states it sees are not forwarded to you. From the outside, a three-second image and a six-minute video are the same shape — one request, one response, nothing in between. + +**What to do instead.** On Router today, nothing: show an indeterminate progress state rather than a percentage you cannot source. If progress is a hard requirement for a specific provider, check whether that provider's partner-proxy routes expose their own polling or streaming and use those directly — a few do, and they are unchanged and fully supported. + +**Status: not yet**, and tied to the queued path: progress needs somewhere to report *to*, which a queued submission provides and a single synchronous call does not. + +## Three forecast buckets are not in the vocabulary + +Router's `error_type` vocabulary is a **closed set of fourteen** buckets — the fourteen the [API reference](/comfy-router-reference) lists and the quickstart points at. Three more are named in that reference's prose as expected additions: `file_download_error`, `cancelled` and `queue_timeout`. They are named, and that is all they are. They are **not members of the set today**: no Router response carries one, a client generated from the contract does not know them, and if Router were handed one internally it substitutes `internal_error` rather than putting it on the wire. So a branch you write for them today is a branch that never runs, and their appearance in the reference is not evidence that Router cancels calls or queues them — it does neither. + +They are forecast in writing rather than left out entirely because `error_type` is deliberately a plain string and not an `enum`, and a client that hard-rejects an unrecognized bucket fails hardest exactly when something has already gone wrong. Naming the additions in advance is how a reader knows the set is open-ended by design. + +**What to do instead.** Handle the fourteen buckets Router actually publishes, listed in full in the [API reference](/comfy-router-reference), and write one fallback branch that treats any unrecognized value as `internal_error`. That fallback is the whole mechanism: it is what lets these three, and any bucket added after your client was written, arrive without breaking you. Branch on the coarse bucket for control flow, and read the per-field `type` inside a `422` body when you need the specific reason. + +**Status: not yet.** Each of the three corresponds to behaviour Router does not have yet, and each joins the vocabulary in the same change that starts emitting it — never before. + +## Router does not cover every partner operation + +Router runs partner *models*. It does not front every operation a partner exposes — the file uploads, the account and asset reads, the provider-specific management calls, the streaming chat endpoints and the submit-and-poll pairs that some partners publish. Nor does Router reshape any of them: it forwards a model's native input and returns its native output unchanged, so there is no unified envelope to port an unsupported operation onto. + +**What to do instead.** The partner-proxy routes under `/proxy/…` remain fully supported on the same host, with the same credential, and they are the answer for anything Router does not cover. They are not deprecated, they are not on a sunset path, and using them alongside Router in the same integration is expected rather than a workaround. Reach for Router when you want one route shape and one credential across many models; reach for `/proxy/…` when you need a specific partner operation, a provider's own streaming response, or the submit-and-poll control that Router deliberately hides. + +**Status: deliberate.** Router narrows the surface on purpose — one route shape is the feature. The proxy surface stays where it is. + +## Next + +- [Comfy Router quickstart](/comfy-router-quickstart) — a first working call in Python or TypeScript. +- [Comfy Router API reference](/comfy-router-reference) — every endpoint, every parameter and every error bucket Router does send. diff --git a/comfy-router-quickstart.mdx b/comfy-router-quickstart.mdx new file mode 100644 index 000000000..5eb92f62c --- /dev/null +++ b/comfy-router-quickstart.mdx @@ -0,0 +1,291 @@ +--- +title: "Comfy Router quickstart" +description: "From nothing to a generated image in about five minutes, in Python and TypeScript, against the Comfy Router." +--- + + +**Comfy Router is not generally available yet.** The routes below — +`POST /v1/models/{provider}/{model}` and its catalog and schema siblings — are +not serving requests yet: an authenticated call answers `404` today. This page +documents the contract they will serve, and is published ahead of that rollout so +the integration is ready to write against. It is not a description of behaviour +you can exercise right now. + + +Comfy Router runs partner models behind one host, one credential and one route shape. This page is the shortest complete path to a generated image: install a client, set a key, send one request, read the result — and see what the first failure looks like before you hit it. + +Base URL: `https://api.comfy.org`. The route is `POST /v1/models/{provider}/{model}`, the request body is the model's own native JSON input, and a `200` carries the model's own native JSON output. Router does not wrap either, so a call you already have written against the partner's API becomes a Router call by changing the host. + +## Why this page uses `bfl/flux-2-pro` + +`bfl/flux-2-pro` returns in about 3.1s at p50, which is the fastest measured path on the Router and is what makes a five-minute first result realistic — a slower model would spend that budget waiting rather than reading. + +It is a convenience, not a requirement. Every other model on the Router is called exactly the same way: same route, same credential header, same error buckets, same `X-Comfy-Request-Id`. Only the model ID, the fields inside the request body, and the shape of the result you read back change. Gemini, for instance, clears comfortably at 72.8s p95 — Router holds the connection for the whole generation rather than returning a job handle to poll. There is no edge ceiling cutting a long call short, but Router does bound the call itself: its own server deadline (10 minutes by default) is the longest it will hold a connection, after which it answers `504` / `deadline_exceeded` and does not bill. Swap the ID and read that model's fields from its own schema (below). + +## Get a key + +Router authenticates with a Comfy API key. Create one at [platform.comfy.org/profile/api-keys](https://platform.comfy.org/profile/api-keys), then put it in the environment — both samples below read `COMFY_API_KEY` and neither takes a key as a literal, so a copy-pasted snippet cannot carry your credential into a commit. + +```bash +export COMFY_API_KEY="comfyui-..." +``` + + +Send a `comfyui-` key in the **`X-API-Key`** header, not `Authorization: Bearer`. +The two headers select different validators: `X-API-Key` is the only inbound +reader of a `comfyui-` key, while a value in `Authorization` is routed to the JWT +branch, where a non-JWT token is a terminal `401 Invalid token` and the key is +never looked up. (`Authorization: Bearer` is correct for a Cloud/Firebase **JWT** +— that is what the generated +[API reference](/comfy-router-reference) means by "bearer token".) + + +Keys are per workspace and carry that workspace's model entitlements and credit balance. A request with no usable credential comes back `401` with `X-Comfy-Error-Type: unauthorized`; one whose workspace cannot run the model comes back `403` / `forbidden`. + +## Python + +Requires Python 3.9+ and `httpx`: + +```bash +pip install httpx +``` + +Save as `quickstart.py` and run it with `python quickstart.py`: + +```python +import os +import uuid + +import httpx + +BASE_URL = os.environ.get("COMFY_ROUTER_BASE_URL", "https://api.comfy.org") +MODEL = "bfl/flux-2-pro" + +# Give the client headroom ABOVE Router's own server deadline (10 minutes by +# default) so a call that reaches the server bound comes back as a typed 504 +# with a request id rather than as an opaque client abort. The deadline bounds +# how long Router holds the connection, not whether the call is billed: if the +# provider completed the generation, it is billed either way. +READ_TIMEOUT_SECONDS = 660.0 + + +class RouterError(Exception): + """A Comfy Router failure, typed by its X-Comfy-Error-Type bucket.""" + + def __init__(self, response: httpx.Response) -> None: + self.error_type = response.headers.get("X-Comfy-Error-Type", "internal_error") + self.request_id = response.headers.get("X-Comfy-Request-Id") + self.status_code = response.status_code + # Parse defensively: an error can arrive as an HTML 502 from a load + # balancer, a plain-text 429, an empty body or a truncated JSON one. The + # status, the bucket and the request id above are the parts worth + # keeping, so a body that will not parse must not replace this exception + # with a JSONDecodeError and lose them. + body = None + if response.headers.get("content-type", "").startswith("application/json"): + try: + body = response.json() + except ValueError: + body = None + detail = body.get("detail") if isinstance(body, dict) else None + # A 422 carries a detail[] array - one entry per rejected field, each + # keeping its own `loc`, `msg` and `type`. Every other bucket carries a + # plain `detail` string. + self.errors = detail if isinstance(detail, list) else [] + self.detail = detail if isinstance(detail, str) else f"HTTP {response.status_code}" + super().__init__(self.detail) + + +def run(model: str, arguments: dict, idempotency_key: str) -> dict: + # Idempotency-Key makes a retry safe on a PAID call: Router replays the + # original response for 24h instead of dispatching (and billing) the + # provider a second time. Reuse the SAME key when retrying one logical + # call; generate a new one for a new call. + response = httpx.post( + f"{BASE_URL}/v1/models/{model}", + headers={ + "X-API-Key": os.environ["COMFY_API_KEY"], + "Idempotency-Key": idempotency_key, + }, + json=arguments, + timeout=httpx.Timeout(READ_TIMEOUT_SECONDS, connect=10.0), + ) + if response.is_error: + raise RouterError(response) + return response.json() + + +result = run( + MODEL, + {"prompt": "a red teapot on a windowsill, morning light"}, + idempotency_key=str(uuid.uuid4()), +) +# Router forwards each provider's native output unchanged, so this path is +# BFL's, not a Router envelope. Reading a different model means reading its own +# output shape. +print("image:", result["result"]["sample"]) + +# The first failure most callers hit: a field the model's input schema requires +# is missing, so Router rejects the request BEFORE any provider call - which is +# why a 422 is never billed. +try: + run(MODEL, {"width": 1024}, idempotency_key=str(uuid.uuid4())) +except RouterError as exc: + print(f"{exc.error_type} (HTTP {exc.status_code}), request id {exc.request_id}") + for entry in exc.errors: + print(" ", ".".join(str(p) for p in entry["loc"]), "->", entry["msg"]) +``` + +```text +image: https://.../out.jpeg +invalid_input (HTTP 422), request id 6f1c... + body.prompt -> Field required +``` + +## TypeScript + +Requires Node 18+ (for built-in `fetch`, `AbortSignal.timeout` and `crypto.randomUUID`) and `tsx` to run TypeScript directly: + +```bash +npm install --save-dev tsx +``` + +Save as `quickstart.mts` — the `.mts` extension is load-bearing, because the file uses top-level `await` and that needs an ES module — and run it with `npx tsx quickstart.mts`: + +```typescript +const BASE_URL = process.env.COMFY_ROUTER_BASE_URL ?? "https://api.comfy.org"; +const MODEL = "bfl/flux-2-pro"; + +// Headroom ABOVE Router's own server deadline (10 minutes by default), so a +// call that reaches the server bound returns a typed 504 with a request id +// rather than aborting locally at the same moment. The deadline bounds how +// long Router holds the connection, not whether the call is billed: if the +// provider completed the generation, it is billed either way. +const CLIENT_TIMEOUT_MS = 660_000; + +interface ValidationEntry { + loc: (string | number)[]; + msg: string; + type: string; +} + +/** A Comfy Router failure, typed by its `X-Comfy-Error-Type` bucket. */ +class RouterError extends Error { + readonly errorType: string; + readonly requestId: string | null; + readonly status: number; + /** A 422 carries a `detail[]` array — one entry per rejected field, each + * keeping its own `loc`, `msg` and `type`. Every other bucket carries a + * plain `detail` string. */ + readonly errors: ValidationEntry[]; + + constructor(response: Response, body: unknown) { + const detail = + typeof body === "object" && body !== null + ? (body as { detail?: unknown }).detail + : undefined; + super(typeof detail === "string" ? detail : `HTTP ${String(response.status)}`); + this.name = "RouterError"; + this.errorType = response.headers.get("X-Comfy-Error-Type") ?? "internal_error"; + this.requestId = response.headers.get("X-Comfy-Request-Id"); + this.status = response.status; + this.errors = Array.isArray(detail) ? (detail as ValidationEntry[]) : []; + } +} + +/** Read a body without letting a non-JSON error page mask the real failure. */ +async function parseBody(response: Response): Promise { + const text = await response.text(); + try { + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +} + +async function run( + model: string, + args: Record, + idempotencyKey: string, +): Promise { + // Idempotency-Key makes a retry safe on a PAID call: Router replays the + // original response for 24h instead of dispatching (and billing) the provider + // a second time. Reuse the SAME key when retrying one logical call. + const response = await fetch(`${BASE_URL}/v1/models/${model}`, { + method: "POST", + headers: { + "X-API-Key": process.env.COMFY_API_KEY ?? "", + "Idempotency-Key": idempotencyKey, + "Content-Type": "application/json", + }, + body: JSON.stringify(args), + signal: AbortSignal.timeout(CLIENT_TIMEOUT_MS), + }); + // Branch on `ok` FIRST: an HTML 502, a plain-text 429 or an empty body must + // still surface the status, the bucket and the request id. + const body = await parseBody(response); + if (!response.ok) throw new RouterError(response, body); + return body as T; +} + +const result = await run<{ result: { sample: string } }>( + MODEL, + { prompt: "a red teapot on a windowsill, morning light" }, + crypto.randomUUID(), +); +// Router forwards each provider's native output unchanged, so this path is +// BFL's, not a Router envelope. Reading a different model means reading its own +// output shape. +console.log("image:", result.result.sample); + +// The first failure most callers hit: a field the model's input schema requires +// is missing, so Router rejects the request BEFORE any provider call - which is +// why a 422 is never billed. +try { + await run(MODEL, { width: 1024 }, crypto.randomUUID()); +} catch (exc) { + if (!(exc instanceof RouterError)) throw exc; + console.log(`${exc.errorType} (HTTP ${String(exc.status)}), request id ${String(exc.requestId)}`); + for (const entry of exc.errors) console.log(" ", entry.loc.join("."), "->", entry.msg); +} +``` + +```text +image: https://.../out.jpeg +invalid_input (HTTP 422), request id 6f1c... + body.prompt -> Field required +``` + +## Reading the `422` + +The `422` is the one error worth understanding before your first real call, because it is the one you cause. It means Router checked your body against the model's own input schema and rejected it — a required field missing, a value outside a bound, an image too small. That check runs BEFORE any provider call, so a `422` costs nothing: no partner spend, no billing question to answer afterwards. It is not the same as a `400`, which is a request-level failure (a malformed cursor, an unreadable envelope) rather than a per-field one. + +Its body is the fal/FastAPI `detail[]` shape: an array with one entry per offending field, each keeping its own `loc` (the path to the field), `msg`, `type` (the specific, provider-level reason — `missing`, `value_error`, `image_too_small`) and, where the reason carries a bound, `ctx`. That per-field granularity is why the samples above keep the array as data instead of flattening it into the exception message. + + +A model whose input schema has not been authored yet resolves to a documented +permissive fallback that admits any JSON object, so it will forward a body +rather than answer `422`. The samples above show the shape you handle once a +schema exists; treat the `422` block as the error path, not as a guaranteed +response to that particular body. + + +That body carries no `error_type` field of its own, so on a `422` the `X-Comfy-Error-Type` header is the *only* machine-readable bucket. Both samples read the bucket from the header first for exactly that reason, which is also what makes one error class enough to cover every failure Router can return. + +`X-Comfy-Request-Id` is on every response — success, `4xx` and `5xx` alike — and is the id to quote in a support request. Both samples attach it to the exception rather than making you re-run with header logging on to find it. + +## Where the model's fields come from + +`prompt` is the only field `bfl/flux-2-pro` requires; `width`, `height`, `seed` and `output_format` are the ones you will reach for next. Rather than reproducing a field list that can drift, read the model's schema live: + +```bash +curl -H "X-API-Key: $COMFY_API_KEY" \ + https://api.comfy.org/v1/models/bfl/flux-2-pro/openapi.json +``` + +That is the same document the server validates your call against, served as a standalone OpenAPI document, so what is published and what is enforced cannot disagree. Take any model ID, append `/openapi.json` to its invocation path, and generate against what comes back. + +## Next + +- [Comfy Router API reference](/comfy-router-reference) — every endpoint, every parameter, and all fourteen error buckets. +- [Comfy Router limitations](/comfy-router-limitations) — what Router does not do today, and what to use instead. diff --git a/comfy-router-reference.mdx b/comfy-router-reference.mdx new file mode 100644 index 000000000..4a52a1388 --- /dev/null +++ b/comfy-router-reference.mdx @@ -0,0 +1,299 @@ +--- +title: "Comfy Router API reference" +description: "Every Comfy Router endpoint, parameter, response body and error bucket, generated from the Comfy API contract." +--- + +{/* + GENERATED FILE -- DO NOT HAND-EDIT. + + Produced from the Comfy API contract by gen_router_reference.py. Edit the + contract and regenerate; an edit made here is overwritten by the next run and + is rejected by the drift gate in the meantime. +*/} + +Comfy Router's canonical, model-ID-addressed routes. + +Base URL: `https://api.comfy.org` + +Every endpoint below is authenticated. Send `Authorization: Bearer `. + +## Endpoints + +### `GET /v1/models` + +**List the models Comfy Router can run.** + +Comfy Router's model catalog - one page of the canonical model IDs that `POST /v1/models/{provider}/{model}` accepts. An SDK calls this on cold start to discover what is runnable, and the `model_not_found` suggestions come from the same catalog, so an ID listed here that then 404s on invocation would be worse than either failure alone. That agreement is structural rather than a promise: an entry's `provider` and `model` are the two path segments of the invocation route and reference the SAME schema components that route's path parameters do, and `id` is those two segments joined by `/`. + +**Parameters** + +| Name | In | Required | Type | Constraints | Description | +| --- | --- | --- | --- | --- | --- | +| `cursor` | query | no | [`RouterPageCursor`](#routerpagecursor) | `pattern: ^[A-Za-z0-9._~+/=-]+$`, `minLength: 1`, `maxLength: 512` | Opaque pagination cursor. Pass a previous page's `next_cursor` to fetch the next page; omit it for the first page. See `RouterPageCursor` for why the value is opaque and why this route paginates by cursor rather than by offset. | +| `limit` | query | no | integer | `maximum: 100`, `default: 20` | Number of models to return in one page. Values above the declared maximum are outside the contract, but this route does not reject them: it serves the maximum instead, and the page size actually served is echoed back as `limit` on the response, so a clamp is always detectable by the caller. Treat the maximum as the real page stride - a client that asks for more and assumes it received more will miss rows. 0 and negative values are also accepted and select the default, which is why no `minimum` is declared: sub-1 is meaningful here, not invalid. | + +**Responses** + +| Status | Body | Headers | Description | +| --- | --- | --- | --- | +| `200` | [`RouterModelListResponse`](#routermodellistresponse) | `X-Comfy-Request-Id` | OK - one page of the model catalog. | +| `400` | [`RouterErrorResponse`](#routererrorresponse) | `X-Comfy-Error-Type`, `X-Comfy-Request-Id` | A Router request-level failure - the request never reached the model, or failed for a reason the model itself did not report. The body is `RouterErrorResponse` and the bucket is repeated on `X-Comfy-Error-Type`. | +| `401` | [`RouterErrorResponse`](#routererrorresponse) | `X-Comfy-Error-Type`, `X-Comfy-Request-Id` | A Router request-level failure - the request never reached the model, or failed for a reason the model itself did not report. The body is `RouterErrorResponse` and the bucket is repeated on `X-Comfy-Error-Type`. | + +### `GET /v1/models/{provider}/{model}` + +**Read one partner model's catalog entry by canonical model ID.** + +Per-model detail for a single Comfy Router model, so a caller can check one model without walking the whole paginated catalog. The SDKs use it to look a model up immediately before invoking it. + +**Parameters** + +| Name | In | Required | Type | Constraints | Description | +| --- | --- | --- | --- | --- | --- | +| `provider` | path | yes | [`RouterProviderSegment`](#routerprovidersegment) | `pattern: ^[a-z0-9]+([._-][a-z0-9]+)*$`, `maxLength: 64` | Lowercase provider segment of the canonical `{provider}/{model}[/{variant}]` model ID - the partner whose model is being run. | +| `model` | path | yes | [`RouterModelSegment`](#routermodelsegment) | `pattern: ^[a-z0-9]+([._-][a-z0-9]+)*$`, `maxLength: 128` | Lowercase model segment of the canonical `{provider}/{model}[/{variant}]` model ID - the model to run within that provider. | + +**Responses** + +| Status | Body | Headers | Description | +| --- | --- | --- | --- | +| `200` | [`RouterModelDetail`](#routermodeldetail) | `X-Comfy-Request-Id` | OK - the model's catalog entry. | +| `404` | [`RouterErrorResponse`](#routererrorresponse) | `X-Comfy-Error-Type`, `X-Comfy-Request-Id` | A Router request-level failure - the request never reached the model, or failed for a reason the model itself did not report. The body is `RouterErrorResponse` and the bucket is repeated on `X-Comfy-Error-Type`. | + +### `POST /v1/models/{provider}/{model}` + +**Run a partner model synchronously by canonical model ID.** + +Comfy Router's canonical, model-ID-addressed entry point. The request body is the partner model's OWN native JSON input and the success response is that model's OWN native JSON output: Router forwards both unchanged instead of imposing a Comfy-shaped envelope, so a caller can move between the partner's API and Router by changing the host. This is the SYNCHRONOUS path, mirroring `POST https://fal.run/{id}` - the response carries the finished result. A queued counterpart, `/v1/queue/models/{provider}/{model}`, is planned and would put fal's `fal.run` / `queue.fal.run` split onto a single host; it is not part of this contract yet. + +**Parameters** + +| Name | In | Required | Type | Constraints | Description | +| --- | --- | --- | --- | --- | --- | +| `provider` | path | yes | [`RouterProviderSegment`](#routerprovidersegment) | `pattern: ^[a-z0-9]+([._-][a-z0-9]+)*$`, `maxLength: 64` | Lowercase provider segment of the canonical `{provider}/{model}[/{variant}]` model ID - the partner whose model is being run. | +| `model` | path | yes | [`RouterModelSegment`](#routermodelsegment) | `pattern: ^[a-z0-9]+([._-][a-z0-9]+)*$`, `maxLength: 128` | Lowercase model segment of the canonical `{provider}/{model}[/{variant}]` model ID - the model to run within that provider. | + +**Request body** + +`application/json` -- [`RouterModelInput`](#routermodelinput) (required) + +The partner model's native JSON input, forwarded to the provider unchanged. + +**Responses** + +| Status | Body | Headers | Description | +| --- | --- | --- | --- | +| `200` | [`RouterModelOutput`](#routermodeloutput) | `X-Comfy-Request-Id` | OK - the partner model's native JSON output, returned unchanged. | +| `403` | [`RouterErrorResponse`](#routererrorresponse) | `X-Comfy-Error-Type`, `X-Comfy-Request-Id` | A Router request-level failure - the request never reached the model, or failed for a reason the model itself did not report. The body is `RouterErrorResponse` and the bucket is repeated on `X-Comfy-Error-Type`. | +| `404` | [`RouterErrorResponse`](#routererrorresponse) | `X-Comfy-Error-Type`, `X-Comfy-Request-Id` | A Router request-level failure - the request never reached the model, or failed for a reason the model itself did not report. The body is `RouterErrorResponse` and the bucket is repeated on `X-Comfy-Error-Type`. | +| `422` | [`RouterValidationErrorResponse`](#routervalidationerrorresponse) | `X-Comfy-Error-Type`, `X-Comfy-Request-Id` | The request reached the model and the model rejected its contents. The body is `RouterValidationErrorResponse`, the fal/FastAPI `detail[]` shape, so each offending field keeps its own specific `type` and `ctx`. `X-Comfy-Error-Type` carries the coarse bucket for the whole response. | +| `503` | [`RouterErrorResponse`](#routererrorresponse) | `X-Comfy-Error-Type`, `X-Comfy-Request-Id` | A Router request-level failure - the request never reached the model, or failed for a reason the model itself did not report. The body is `RouterErrorResponse` and the bucket is repeated on `X-Comfy-Error-Type`. | +| `504` | [`RouterErrorResponse`](#routererrorresponse) | `X-Comfy-Error-Type`, `X-Comfy-Request-Id` | A Router request-level failure - the request never reached the model, or failed for a reason the model itself did not report. The body is `RouterErrorResponse` and the bucket is repeated on `X-Comfy-Error-Type`. | + +### `GET /v1/models/{provider}/{model}/openapi.json` + +**Read one partner model's input schema as an OpenAPI document.** + +The per-model input schema for a single Comfy Router model, served as a standalone OpenAPI document, so a caller - an SDK, a codegen tool, or an agent - can discover a model's arguments without reading Comfy's prose docs. It mirrors fal's per-model schema endpoint, and it is the discovery mechanism the SDK quickstart depends on. + +**Parameters** + +| Name | In | Required | Type | Constraints | Description | +| --- | --- | --- | --- | --- | --- | +| `provider` | path | yes | [`RouterProviderSegment`](#routerprovidersegment) | `pattern: ^[a-z0-9]+([._-][a-z0-9]+)*$`, `maxLength: 64` | Lowercase provider segment of the canonical `{provider}/{model}[/{variant}]` model ID - the partner whose model is being run. | +| `model` | path | yes | [`RouterModelSegment`](#routermodelsegment) | `pattern: ^[a-z0-9]+([._-][a-z0-9]+)*$`, `maxLength: 128` | Lowercase model segment of the canonical `{provider}/{model}[/{variant}]` model ID - the model to run within that provider. | + +**Responses** + +| Status | Body | Headers | Description | +| --- | --- | --- | --- | +| `200` | [`RouterModelInputSchemaDocument`](#routermodelinputschemadocument) | `X-Comfy-Request-Id`, `ETag`, `Cache-Control` | OK - the model's input schema, as a standalone OpenAPI document. | +| `304` | - | `X-Comfy-Request-Id`, `ETag`, `Cache-Control` | Not Modified - the document is unchanged since the `ETag` the caller sent in `If-None-Match`. No body is returned. | +| `404` | [`RouterErrorResponse`](#routererrorresponse) | `X-Comfy-Error-Type`, `X-Comfy-Request-Id` | A Router request-level failure - the request never reached the model, or failed for a reason the model itself did not report. The body is `RouterErrorResponse` and the bucket is repeated on `X-Comfy-Error-Type`. | +| `500` | [`RouterErrorResponse`](#routererrorresponse) | `X-Comfy-Error-Type`, `X-Comfy-Request-Id` | A Router request-level failure - the request never reached the model, or failed for a reason the model itself did not report. The body is `RouterErrorResponse` and the bucket is repeated on `X-Comfy-Error-Type`. | + +## Error buckets + +Coarse, machine-readable bucket for a Router failure, mirrored on the `X-Comfy-Error-Type` response header so a caller can branch without parsing the body. The set is closed at fourteen values: the six request-level buckets `invalid_input`, `content_policy_violation`, `provider_error`, `provider_timeout`, `insufficient_credits` and `model_not_found`, plus the transport-level `unauthorized`, `forbidden`, `concurrency_limit_exceeded`, `client_disconnected`, `internal_error`, `deadline_exceeded`, `not_enabled` and `service_unavailable`. + +### Request-level buckets + +Raised for a request Router accepted and then could not complete. + +| `error_type` | Meaning | +| --- | --- | +| `invalid_input` | The request was rejected before it reached the model - a malformed body, a malformed or expired pagination cursor, or an input the model's own schema does not accept. | +| `content_policy_violation` | The provider refused the request on content-policy grounds. The refusal is deterministic: re-sending the same input will be refused again. | +| `provider_error` | The partner provider reported a failure of its own, or returned a response Router could not interpret as a result. | +| `provider_timeout` | The partner provider did not answer within its deadline. This bucket is the PROVIDER timing out and never Router's own server deadline, which is reported as `deadline_exceeded` - the two share `504` and are separated because they name different causes: this one says the partner failed, that one says Comfy stopped holding the connection. | +| `insufficient_credits` | The calling workspace does not have enough credits to run the model. | +| `model_not_found` | The `{provider}/{model}` ID names no model Router can run; an unknown provider lands here too. `detail` carries up to three suggestions drawn from the models the caller is entitled to see. | + +### Transport-level buckets + +Raised by Router itself, before or around the call to the model. + +| `error_type` | Meaning | +| --- | --- | +| `unauthorized` | The request carried no usable credential. | +| `forbidden` | The credential is valid but is not entitled to this model or this operation. | +| `concurrency_limit_exceeded` | The workspace already has as many calls in flight as it is allowed; retry once one of them finishes. | +| `client_disconnected` | The caller closed the connection before Router could return a result. It is logged rather than delivered - there is no socket left to write it to - and it is an attribution, not a billing outcome: a provider generation that completed is billed regardless of whether the caller received the response. | +| `internal_error` | Router itself failed. It is also the value a client should treat any UNRECOGNIZED bucket as, so a later addition to the set does not break a client generated before it. | +| `deadline_exceeded` | Comfy stopped holding the connection at its own configured bound before an answer arrived. It shares `504` with `provider_timeout` and the pair says which side ran out of time; this one is Comfy's own bound, so nothing about the request was rejected and the same request may be retried. It says nothing about the charge: a provider generation that completed is billed regardless of whether the caller received the response. | +| `not_enabled` | Comfy Router is not switched on for this caller yet. Nothing about the request is wrong and the model exists, which is why this is not `model_not_found`; it shares `403` with `forbidden` and is NOT the same thing, because `forbidden` is an entitlement decision about the caller while this is a state of the rollout. It is TERMINAL: do not retry, and do not treat it as an outage. | +| `service_unavailable` | A service Comfy Router depends on is temporarily unavailable and the caller did nothing wrong. Retry it with backoff: it is the one bucket here whose condition clears on its own, without the caller changing the request and without a concurrency slot freeing, which is what distinguishes it from the other retryable answers (`concurrency_limit_exceeded`, `deadline_exceeded`). It is separate from `internal_error` - which is a `500` and means Router itself failed - so a client can tell "come back shortly" from "this call is not going to work". | + +## Response headers + +| Header | Type | Description | +| --- | --- | --- | +| `Cache-Control` | string | Freshness directives for the served schema document. `private` because the route is authenticated - the document itself is not caller-specific, but a shared cache must not hold a response to an authenticated request - and `must-revalidate` so a stale copy is revalidated against the `ETag` rather than served on. | +| `ETag` | string | Strong entity tag over the served document's bytes, for `GET /v1/models/{provider}/{model}/openapi.json`. A per-model schema changes rarely and an SDK re-fetches it often, so a caller should store this value and send it back as `If-None-Match` to get a `304` instead of the document. | +| `X-Comfy-Error-Type` | [`RouterErrorType`](#routererrortype) | Coarse, machine-readable bucket for the failure, set by Router on every error response. It carries the same value as `RouterErrorResponse.error_type`, and on the `422` it is the ONLY machine-readable bucket, because that body is the fal/FastAPI `detail[]` shape and has no `error_type` field of its own. A client can therefore branch on this header alone, before deciding which of the two Router error bodies it received. | +| `X-Comfy-Request-Id` | string | Server-generated identifier for this call, present on EVERY Router response - success, 4xx and 5xx alike, because an error response is exactly when a user needs an id to quote in a support request. The SAME value is written into the call's usage/audit event, which is what lets a complaint about a charge be joined to the charge itself instead of searched for by timestamp. | + +## Per-model input schemas + +A model's own input fields are not reproduced here. Read them live from `GET /v1/models/{provider}/{model}/openapi.json`, which serves the same document the server validates the call against, so what is published and what is enforced cannot drift apart. Take a model ID from `GET /v1/models`, append `/openapi.json` to its invocation path, and generate against the document you get back. + +## Schemas + +### RouterChargesOnPolicyRejection + +Whether a call this model REFUSES on content-policy grounds is nevertheless charged to the caller. Providers differ, the difference is invisible at call time, and a user who sees an error and a charge for the same call has no way to have known - so it is stated per model, before the call, rather than left to per-provider folklore. + +Type: `string` + +### RouterErrorResponse + +Router's request-level error body: what is returned when the request never reached the model, or failed for a reason the model itself did not report - auth, quota, an unknown model ID, or provider transport. A model-level validation failure has its own shape, `RouterValidationErrorResponse`, because flattening a FastAPI `detail[]` array into this `detail` string would destroy the per-field granularity an SDK branches on. + +| Field | Type | Required | Constraints | Description | +| --- | --- | --- | --- | --- | +| `detail` | string | yes | - | Human-readable description of the failure, safe to surface to an end user. Not machine-parsed - branch on `error_type` instead. | +| `error_type` | [`RouterErrorType`](#routererrortype) | yes | - | Coarse, machine-readable bucket for a Router failure, mirrored on the `X-Comfy-Error-Type` response header so a caller can branch without parsing the body. The set is closed at fourteen values: the six request-level buckets `invalid_input`, `content_policy_violation`, `provider_error`, `provider_timeout`, `insufficient_credits` and `model_not_found`, plus the transport-level `unauthorized`, `forbidden`, `concurrency_limit_exceeded`, `client_disconnected`, `internal_error`, `deadline_exceeded`, `not_enabled` and `service_unavailable`. | + +### RouterErrorType + +Coarse, machine-readable bucket for a Router failure, mirrored on the `X-Comfy-Error-Type` response header so a caller can branch without parsing the body. The set is closed at fourteen values: the six request-level buckets `invalid_input`, `content_policy_violation`, `provider_error`, `provider_timeout`, `insufficient_credits` and `model_not_found`, plus the transport-level `unauthorized`, `forbidden`, `concurrency_limit_exceeded`, `client_disconnected`, `internal_error`, `deadline_exceeded`, `not_enabled` and `service_unavailable`. + +Type: `string` + +### RouterModelBilling + +Per-model billing FACTS a caller needs before invoking - not prices. Usage and cost figures never appear here. + +| Field | Type | Required | Constraints | Description | +| --- | --- | --- | --- | --- | +| `charges_on_policy_rejection` | [`RouterChargesOnPolicyRejection`](#routerchargesonpolicyrejection) | yes | - | Whether a call this model REFUSES on content-policy grounds is nevertheless charged to the caller. Providers differ, the difference is invisible at call time, and a user who sees an error and a charge for the same call has no way to have known - so it is stated per model, before the call, rather than left to per-provider folklore. | + +### RouterModelDetail + +Per-model detail for one Comfy Router model: everything the catalog listing reports for it, plus the per-model fields that only the single-model route carries. + +Composes [`RouterModelListEntry`](#routermodellistentry), [`RouterModelDetailFields`](#routermodeldetailfields). + +Type: `object` + +### RouterModelDetailFields + +The half of `RouterModelDetail` the catalog listing does NOT carry: per-model fields worth one lookup but not worth repeating on every entry of a paginated catalog page. + +| Field | Type | Required | Constraints | Description | +| --- | --- | --- | --- | --- | +| `input_schema_url` | string | no | `format: uri`, `pattern: ^https://`, `maxLength: 2048` | Pointer to this model's input schema document - the description of the body `POST /v1/models/{provider}/{model}` accepts for this model. Only the POINTER is part of this contract: the document it addresses is authored separately. Absent when no schema has been authored for the model. | + +### RouterModelId + +A canonical Comfy Router model ID, `{provider}/{model}` - exactly the value that addresses the model on `POST /v1/models/{provider}/{model}`, so a caller can interpolate it into that path without re-deriving it from anything. Its `pattern` is `RouterProviderSegment` and `RouterModelSegment` joined by a single `/`, and `maxLength` is their sum plus that separator. + +Type: `string` -- `pattern: ^[a-z0-9]+([._-][a-z0-9]+)*/[a-z0-9]+([._-][a-z0-9]+)*$`, `maxLength: 193` + +### RouterModelInput + +A partner model's native JSON input document, forwarded to the provider as-is. Its concrete shape is owned by the partner rather than by Comfy, so this is an open object: Router does not narrow, rename, or re-envelope the fields. It is a named component (never an inline anonymous object) because ComfyUI's spec-driven codegen needs a class to generate. + +Type: `object` + +### RouterModelInputSchemaDocument + +A standalone OpenAPI document describing ONE Comfy Router model's input - the body `POST /v1/models/{provider}/{model}` accepts for that model. It is what `GET /v1/models/{provider}/{model}/openapi.json` returns. + +Type: `object` + +### RouterModelListEntry + +One entry in the Router model catalog: the identity of a runnable model, and nothing else. The per-model detail route composes this same entry rather than restating it, which is why the name is `...ListEntry` and not `...Summary` - there must be exactly one definition of what a catalog entry is. Per-model detail and the per-model input/output schemas are their own routes, so this shape stays the minimum a caller needs in order to invoke the model - deliberately, because this is the payload an SDK fetches on cold start. `id` is `provider` and `model` joined by `/`; the two fields are carried separately as well so a caller composes the invocation path without splitting a string. + +| Field | Type | Required | Constraints | Description | +| --- | --- | --- | --- | --- | +| `id` | [`RouterModelId`](#routermodelid) | yes | `pattern: ^[a-z0-9]+([._-][a-z0-9]+)*/[a-z0-9]+([._-][a-z0-9]+)*$`, `maxLength: 193` | A canonical Comfy Router model ID, `{provider}/{model}` - exactly the value that addresses the model on `POST /v1/models/{provider}/{model}`, so a caller can interpolate it into that path without re-deriving it from anything. Its `pattern` is `RouterProviderSegment` and `RouterModelSegment` joined by a single `/`, and `maxLength` is their sum plus that separator. | +| `provider` | [`RouterProviderSegment`](#routerprovidersegment) | yes | `pattern: ^[a-z0-9]+([._-][a-z0-9]+)*$`, `maxLength: 64` | Lowercase `provider` segment of the canonical `{provider}/{model}[/{variant}]` model ID - the partner whose model is being addressed. The invocation route's `provider` path parameter and a catalog entry's `provider` field both reference this one schema, which is what keeps the listed IDs and the accepted IDs from drifting apart. | +| `model` | [`RouterModelSegment`](#routermodelsegment) | yes | `pattern: ^[a-z0-9]+([._-][a-z0-9]+)*$`, `maxLength: 128` | Lowercase `model` segment of the canonical `{provider}/{model}[/{variant}]` model ID - the model to run within that provider. Shared by the invocation route's `model` path parameter and a catalog entry's `model` field, for the same no-drift reason as `RouterProviderSegment`. | +| `billing` | [`RouterModelBilling`](#routermodelbilling) | yes | - | Per-model billing FACTS a caller needs before invoking - not prices. Usage and cost figures never appear here. | + +### RouterModelListResponse + +One page of the Router model catalog. + +| Field | Type | Required | Constraints | Description | +| --- | --- | --- | --- | --- | +| `data` | array of [`RouterModelListEntry`](#routermodellistentry) | yes | - | The models on this page, at most `limit` of them. | +| `has_more` | boolean | yes | - | Whether another page exists beyond this one. Keep walking while this is true; do not infer the end of the catalog from a short or empty `data`. | +| `next_cursor` | [`RouterPageCursor`](#routerpagecursor) | no | `pattern: ^[A-Za-z0-9._~+/=-]+$`, `minLength: 1`, `maxLength: 512` | An OPAQUE cursor into a Router list. It is produced by the server and only ever round-tripped: it is not an offset, not a model ID, not ordered, and not stable across catalog rebuilds, so parsing one, incrementing one, or persisting one beyond the walk it came from are all outside the contract. Cursor rather than offset because the catalog is a moving list - an offset walk silently skips or repeats entries when entries are added or removed mid-walk, and a caller cannot tell that it happened. | +| `limit` | integer | yes | `minimum: 1`, `maximum: 100` | The page size actually served. A requested `limit` above the maximum is CLAMPED down to the maximum rather than rejected, so this can be smaller than the value asked for - paginate with this number, not with the one you sent, or you will assume rows you never received. | + +### RouterModelOutput + +A partner model's native JSON output document, returned to the caller as-is. Its concrete shape is owned by the partner rather than by Comfy, so this is an open object: Router does not narrow, rename, or re-envelope the fields. It is a named component (never an inline anonymous object) because ComfyUI's spec-driven codegen needs a class to generate. + +Type: `object` + +### RouterModelSegment + +Lowercase `model` segment of the canonical `{provider}/{model}[/{variant}]` model ID - the model to run within that provider. Shared by the invocation route's `model` path parameter and a catalog entry's `model` field, for the same no-drift reason as `RouterProviderSegment`. + +Type: `string` -- `pattern: ^[a-z0-9]+([._-][a-z0-9]+)*$`, `maxLength: 128` + +### RouterPageCursor + +An OPAQUE cursor into a Router list. It is produced by the server and only ever round-tripped: it is not an offset, not a model ID, not ordered, and not stable across catalog rebuilds, so parsing one, incrementing one, or persisting one beyond the walk it came from are all outside the contract. Cursor rather than offset because the catalog is a moving list - an offset walk silently skips or repeats entries when entries are added or removed mid-walk, and a caller cannot tell that it happened. + +Type: `string` -- `pattern: ^[A-Za-z0-9._~+/=-]+$`, `minLength: 1`, `maxLength: 512` + +### RouterProviderSegment + +Lowercase `provider` segment of the canonical `{provider}/{model}[/{variant}]` model ID - the partner whose model is being addressed. The invocation route's `provider` path parameter and a catalog entry's `provider` field both reference this one schema, which is what keeps the listed IDs and the accepted IDs from drifting apart. + +Type: `string` -- `pattern: ^[a-z0-9]+([._-][a-z0-9]+)*$`, `maxLength: 64` + +### RouterValidationErrorContext + +The violated bound for one `RouterValidationErrorDetail`, carried from the provider verbatim - for example `{"limit_value": 8}` alongside `greater_than`, `{"min_width": 512}` alongside `image_too_small`, or `{"max_size_bytes": 10485760}` alongside `file_too_large`. The key set is specific to the provider and the error type, so this is deliberately an open object: narrowing it to a fixed field list, or folding it into the `msg` string, is precisely how a ported integration compiles and then silently loses the branch that read the bound. Absent when the error type carries no bound. + +Type: `object` + +### RouterValidationErrorDetail + +One model-level validation failure, in the fal/FastAPI form. `type` carries the SPECIFIC provider reason - `value_error`, `missing`, `image_too_small`, `unsupported_audio_format`, `greater_than`, `file_too_large` and the rest - which is the granularity `RouterErrorType`'s coarse bucket cannot express. It is an open string and not an `enum` for the same reason: the provider vocabulary runs to roughly 48 values across two tiers and grows on the provider's release cycle, not ours, and an unmodelled value must reach the caller rather than fail deserialization. + +| Field | Type | Required | Constraints | Description | +| --- | --- | --- | --- | --- | +| `loc` | array of any | yes | - | Path to the offending field, outermost segment first - for example `["body", "image_url"]`, or `["body", "images", 0]` where an integer indexes into an array. | +| `msg` | string | yes | - | Human-readable description of this single failure. | +| `type` | string | yes | - | Specific, machine-readable reason for this failure, passed through from the provider unchanged. This is the value a typed SDK exception hierarchy branches on; `error_type` on the response header is only its coarse bucket. | +| `ctx` | [`RouterValidationErrorContext`](#routervalidationerrorcontext) | no | - | The violated bound for one `RouterValidationErrorDetail`, carried from the provider verbatim - for example `{"limit_value": 8}` alongside `greater_than`, `{"min_width": 512}` alongside `image_too_small`, or `{"max_size_bytes": 10485760}` alongside `file_too_large`. The key set is specific to the provider and the error type, so this is deliberately an open object: narrowing it to a fixed field list, or folding it into the `msg` string, is precisely how a ported integration compiles and then silently loses the branch that read the bound. Absent when the error type carries no bound. | +| `input` | [`RouterValidationErrorInput`](#routervalidationerrorinput) | no | - | The offending input value, echoed back verbatim so a caller can see what was rejected without re-deriving it from `loc`. Any JSON type - string, number, boolean, array, object or null - so this schema is deliberately left untyped rather than narrowed to an object. Absent when the provider does not echo the input back. | + +### RouterValidationErrorInput + +The offending input value, echoed back verbatim so a caller can see what was rejected without re-deriving it from `loc`. Any JSON type - string, number, boolean, array, object or null - so this schema is deliberately left untyped rather than narrowed to an object. Absent when the provider does not echo the input back. + +### RouterValidationErrorResponse + +Router's model-level `422` body, in the fal/FastAPI form: the request was well-formed enough to reach the model and the model rejected its contents. Note it carries no `error_type` of its own - that is what `X-Comfy-Error-Type` on the response is for, so a client can read the coarse bucket off the header without first deciding which of the two Router error bodies it received. + +| Field | Type | Required | Constraints | Description | +| --- | --- | --- | --- | --- | +| `detail` | array of [`RouterValidationErrorDetail`](#routervalidationerrordetail) | yes | - | Every validation failure found on the request, one entry per offending field. | diff --git a/openapi-v2.yaml b/openapi-v2.yaml index 7d3801366..90995725f 100644 --- a/openapi-v2.yaml +++ b/openapi-v2.yaml @@ -556,7 +556,7 @@ paths: description: 'Emitted the moment each output asset is committed, carrying the same `Output` object that appears on `job.outputs[]`. A latency optimization only: it lets a client render each result as it lands instead of waiting for the terminal `status` event. It is delivered best-effort over the live broadcast path — an output whose durable asset record is not yet resolvable when its node finishes may be delivered on a slightly later event or, failing that, only in the terminal `status` snapshot — so the authoritative, complete set of outputs is always `job.outputs[]` on `GET /api/v2/jobs/{id}` and on the terminal `status` event. A client must therefore treat these as additive hints and must not assume it receives one per output.' schema: '#/components/schemas/Output' log: - description: Selected execution log lines. Best-effort diagnostics; the one event type with no snapshot equivalent. NOT YET EMITTED by the server in the first iteration — reserved in the catalog so the wire contract is stable. Clients must not depend on receiving this event yet. + description: 'Selected execution log lines. Best-effort diagnostics. Its snapshot equivalent is `job.logs` on `GET /api/v2/jobs/{id}`, which carries the whole log the run produced, read back once the run has finished; this event is the live view of that same output, carrying lines while the run is still going. NOT YET EMITTED by the server in the first iteration — reserved in the catalog so the wire contract is stable. Clients must not depend on receiving this event yet: to get a log today, stream to a terminal status and re-read the job.' x-sse-not-yet-emitted: true schema: '#/components/schemas/LogEvent' parameters: @@ -810,6 +810,10 @@ components: allOf: - $ref: '#/components/schemas/JobError' nullable: true + logs: + allOf: + - $ref: '#/components/schemas/JobLogs' + description: 'What the run printed. **Only jobs run on the serverless platform** (a `{deployment}.run.comfy.app` host) carry it. Comfy Cloud and self-hosted callers never receive it, so on those surfaces the field is always absent and a client should not wait for one. Where it is populated it is captured for every job, success and failure alike, since a job that succeeds while producing the wrong thing is exactly what a failure-only log cannot explain. It lives as long as the job it belongs to: nothing ages it out ahead of the job''s own `expires_at`, so a job never outlives its log. **Absent, not null**, when there is none: the surface does not populate it at all, the job has not finished, the job predates log capture, or the job ran on the public demo deployment, which captures and stores the log like every other serverless deployment but withholds it on read, because that surface takes callers with no credential and a job id would otherwise be the only thing between one anonymous caller and another''s run. Those cases are deliberately not distinguished, because a caller''s next action is the same in all of them, which is to stop expecting a log. Returned by `GET /api/v2/jobs/{id}` only. It is deliberately absent from the job object on `POST /api/v2/jobs`, on `POST /api/v2/jobs/{id}/cancel`, and on the SSE `status` event: the last is pushed on every transition to every open stream, and a log on each frame would pay for the whole thing repeatedly to deliver it once. A client that streams to a terminal status and wants the log re-reads the job.' metrics: type: object description: 'Values are nullable (a metric not yet available — e.g. `execution_ms` before a job starts running — is `null`, not omitted); the example below is deliberately all-non-null purely to work around a Spectral/nimma lint-tooling crash on a literal `null` inside a schema `example` combined with `additionalProperties.nullable: true` — the schema itself is unchanged and still allows null values at runtime.' @@ -821,6 +825,24 @@ components: execution_ms: 42000 urls: $ref: '#/components/schemas/JobUrls' + JobLogs: + type: object + description: 'A job''s captured execution log. Diagnostics, not a contract on content: this is whatever the workflow''s own code and nodes wrote to standard output, in the order they wrote it, so nothing about its shape is stable between runs or between versions of a distribution. It is **untrusted text** — a workflow chooses what goes in it — and must be rendered as plain text rather than interpreted.' + required: + - text + - truncated + - captured_at + properties: + text: + type: string + description: The captured output. + truncated: + type: boolean + description: '`text` is the TAIL of a longer run. Implementations bound what they capture and store, so a workflow that prints megabytes keeps its last lines — where a failure normally is — instead of being dropped whole. True with an empty `text` means the log was captured and then shed entirely to fit.' + captured_at: + type: string + format: date-time + description: When the run's output was read back off the worker. JobWorkflowResponse: type: object description: The workflow behind a job. See GET /api/v2/jobs/{id}/workflow's description for exactly when `format` is `save` vs `api`.