diff --git a/spec/openapi.yaml b/spec/openapi.yaml index 7d38013..ca254be 100644 --- a/spec/openapi.yaml +++ b/spec/openapi.yaml @@ -46,14 +46,79 @@ paths: bytes — a client-declared `expected_hash` is verified, never trusted. + If location metadata (GPS, XMP, IPTC) is removed from an image before + + it is stored, the asset''s `hash` is the hash of the stored bytes and + + can differ from `expected_hash`. On Comfy Cloud, an upload whose + + bytes open as a JPEG, PNG, WebP or classic TIFF (including TIFF-based + + camera RAW such as DNG, NEF, ARW and CR2, but not BigTIFF) and that + + is over 50 MiB is refused with 422 `validation_error`, whatever its + + `content_type` and whether or not it carries location metadata. A + + request that declares a `Content-Length` over Comfy Cloud''s 200 MB + + (200,000,000-byte) body limit is refused before that check with a + + plain 413 that does not use the `ErrorEnvelope` shape. A body sent + + without a declared length is cut off when it passes that limit, and + + no 413 is sent: a cut-off while the fields before `file` are read + + fails with 422 `invalid_body`, and a cut-off inside the file usually + + fails with 500 `upstream_error`. Nothing after the `file` field is + + read, so a body that only passes the limit there is uploaded + + normally. + + Deployment-scoped surfaces neither strip location metadata nor apply + + the 50 MiB image cap; they limit the request body to 110 MB + + (110,000,000 bytes). A request that declares a `Content-Length` over + + that limit is refused with the same plain 413; a body sent without a + + declared length is cut off when it passes the limit, and the upload + + then fails with an error response that is not guaranteed to be a 413. + The uploaded asset is referenceable immediately; any content scanning runs in the background and does not block the response. - ~100 MB single-request expectation for v1; chunked/resumable large + An upload is a single request. v1 expects files of up to ~100 MB; + + the hard cap is the body limit of the surface it is sent to (above). + + Chunked/resumable large uploads are a deliberate open question. + + + On Comfy Cloud an upload fails with 422 `upload_timed_out` when no + + request bytes arrive for 30 seconds, or when the whole request takes + + longer than 10 minutes, however steadily bytes keep arriving. + + On Comfy Cloud an upload can also be refused with 503 `upload_busy` + + and `Retry-After` when the server is busy with other uploads: nothing - uploads are a deliberate open question. + was stored, so the same upload can be sent again after `Retry-After`. + + On a Developer Platform deployment the same 422 `upload_timed_out` + + applies when no request bytes arrive for 30 seconds, or when the whole + + request takes longer than 20 minutes. ' x-streaming-upload: true @@ -84,13 +149,13 @@ paths: example: photo.png expected_hash: type: string - description: Optional client-computed blake3 (`blake3:`). Verified against the server-computed hash; mismatch is rejected with 409 `hash_mismatch` and no asset is minted. + description: Optional client-computed blake3 (`blake3:`) of the bytes sent. Verified against the server-computed blake3 of the received bytes, not of the stored bytes; mismatch is rejected with 409 `hash_mismatch`. On serverless deployments no asset is minted and no existing asset's retention is changed; elsewhere the upload is stored before the check, so the 409 returns no asset and a retry with the corrected expectation dedups onto it. example: blake3:9f8a1c0d... tags: type: array items: type: string - description: Category tags (e.g. `input`). + description: 'Category tags (e.g. `input`). A create carries at most 64 tags, each a non-empty UTF-8 string of at most 256 bytes (surrounding whitespace included) with no NUL byte. A deployment adds `input` to every asset and counts it, so it takes at most 63 of the caller''s own; the `model_type:` tags derived for a model asset are not counted. A tag set breaking a limit is refused with 422. Cloud''s hosted API also caps this encoded JSON field at 8 KiB, so a long tag set can be refused (422 `invalid_body`) below these limits. Invalid UTF-8 or a lone surrogate escape (e.g. `\ud800`) inside a JSON-encoded tag is not refused: JSON decoding replaces it with U+FFFD (3 bytes each), and the 256-byte limit applies to the replaced value.' expires_in: type: integer minimum: 60 @@ -123,12 +188,18 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + '413': + description: The upload is over the body limit. On Comfy Cloud (200 MB, 200,000,000 bytes) and on deployment-scoped surfaces (110 MB, 110,000,000 bytes) the request declared a `Content-Length` over it; the server's body limit sends this before the upload is handled, so the body is not an `ErrorEnvelope` and its shape is not part of this contract. The self-hosted proxy instead refuses a file over its configured cap (100 MiB by default) with an `ErrorEnvelope` whose code is `payload_too_large`. '422': - description: '`idempotency_key_reuse` or validation failure.' + description: '`idempotency_key_reuse`, `input_blocked` (the bytes are already stored and flagged by content moderation, so `file_path` is not registered for them), `upload_timed_out` (the client stopped sending or sent too slowly; the server cannot recover the upload, so retry it from a faster connection), or validation failure — including a `tags` set over the limits above or a disallowed tag (`invalid_request` on a deployment or the self-hosted proxy; a deployment answers a body that is not `multipart/form-data` with a boundary with a plain 500 instead). On a deployment that includes a truncated `file` part: the body ends inside the part, before the delimiter that ends it. A body that stops right at that delimiter, without the final `--`, is not rejected: the part reads as complete and is stored. Comfy Cloud''s asset upload answers validation failure instead with `invalid_content_type` (the request is not `multipart/form-data` with a boundary), `invalid_body` (a malformed multipart body or upload field) or `validation_error` (the platform refused the upload, for example an image over the size cap or a disallowed tag).' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + '429': + $ref: '#/components/responses/RateLimited' + '503': + $ref: '#/components/responses/DeploymentLookupUnavailable' '500': $ref: '#/components/responses/UpstreamError' /api/v2/assets/from-hash: @@ -152,6 +223,15 @@ paths: deliberately indistinguishable (`404` `blob_not_found`). + + The lookup key is the `hash` an upload returned, which is the hash of + + the stored bytes. When location metadata was removed from an image + + before it was stored, that differs from the client''s digest of the + + file it sent, and the client''s digest finds nothing. + ' requestBody: required: true @@ -172,6 +252,7 @@ paths: type: array items: type: string + description: 'A create carries at most 64 tags, each a non-empty UTF-8 string of at most 256 bytes (surrounding whitespace included) with no NUL byte. A deployment adds `input` to every asset and counts it, so it takes at most 63 of the caller''s own; the `model_type:` tags derived for a model asset are not counted. A tag set breaking a limit is refused with 422. Invalid UTF-8 or a lone surrogate escape (e.g. `\ud800`) inside a JSON-encoded tag is not refused: JSON decoding replaces it with U+FFFD (3 bytes each), and the 256-byte limit applies to the replaced value.' expires_in: type: integer minimum: 60 @@ -201,6 +282,16 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + '422': + description: 'The `file_path`, `tags` or `expires_in` is malformed, or `tags` breaks the limits above or holds a disallowed tag: `invalid_request` on a deployment, `validation_error` on Cloud. A deployment checks the tags even when an identical reference exists.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '429': + $ref: '#/components/responses/RateLimited' + '503': + $ref: '#/components/responses/DeploymentLookupUnavailable' '500': $ref: '#/components/responses/UpstreamError' /api/v2/assets/by-hash/{hash}: @@ -209,7 +300,7 @@ paths: tags: - assets summary: Existence check for a blob by blake3 hash - description: '`200` if the calling account can mint from this blob, `404` otherwise. Same account-scoping as `from-hash`; lets a client decide between the dedup fast-path and a full upload before sending bytes.' + description: '`200` if the calling account can mint from this blob, `404` otherwise. Same account-scoping as `from-hash`; lets a client decide between the dedup fast-path and a full upload before sending bytes. Use the `hash` an upload returned: for an image whose location metadata was removed before storage, the client''s digest of the file it sent names no stored blob. A refusal answers its status with no body, so a `403`''s code is not readable here.' parameters: - $ref: '#/components/parameters/BlakeHash' responses: @@ -219,6 +310,12 @@ paths: description: No blob the caller may mint from. '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/RateLimited' + '503': + $ref: '#/components/responses/DeploymentLookupUnavailable' '500': $ref: '#/components/responses/UpstreamError' /api/v2/assets/{id}: @@ -243,6 +340,10 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/RateLimited' + '503': + $ref: '#/components/responses/DeploymentLookupUnavailable' '500': $ref: '#/components/responses/UpstreamError' delete: @@ -281,6 +382,10 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + '429': + $ref: '#/components/responses/RateLimited' + '503': + $ref: '#/components/responses/DeploymentLookupUnavailable' '500': $ref: '#/components/responses/UpstreamError' /api/v2/assets/{id}/content: @@ -336,6 +441,16 @@ paths: $ref: '#/components/responses/NotFound' '416': description: Range not satisfiable. + '429': + $ref: '#/components/responses/RateLimited' + '451': + description: '`content_blocked` on a deployment: content moderation flagged these bytes, so they are not served.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '503': + $ref: '#/components/responses/DeploymentLookupUnavailable' '500': $ref: '#/components/responses/UpstreamError' /api/v2/jobs: @@ -414,12 +529,37 @@ paths: additionalProperties: true extra_data: type: object - description: 'Per-prompt ComfyUI `extra_data`, same shape as Comfy Cloud and local ComfyUI. Closed object: only the enumerated keys are accepted, keeping the contract fully typed. Forwarded to the worker per-prompt, never persisted, and excluded from idempotency comparison.' + description: 'Per-prompt ComfyUI `extra_data`, same shape as Comfy Cloud and local ComfyUI. Closed object: only the enumerated keys are accepted, keeping the contract fully typed. Forwarded to the worker per-prompt and excluded from idempotency comparison. On a deployment it is dispatch-only and never stored; on Comfy Cloud it is persisted with the prompt, because the worker needs it, and redacted on every path that returns a workflow to a caller. + + + Send the one credential you hold: an API key as `api_key_comfy_org`, or the session token an interactively signed-in client has instead as `auth_token_comfy_org`. Sending both is accepted and both are forwarded, but it is not a supported combination and which one a node uses is not defined here. Note a session token is short-lived and is not re-minted for you, so one submitted long before it executes may expire in the queue.' additionalProperties: false properties: api_key_comfy_org: type: string description: API key for partner (API) nodes. + auth_token_comfy_org: + type: string + description: Session bearer token for partner (API) nodes — the equivalent of `api_key_comfy_org` for a caller authenticated by session rather than by key. + preview_method: + type: string + description: ComfyUI's preview method for this prompt, passed to ComfyUI as sent, which validates it. Used only for jobs sent by Comfy Cloud's editor; ignored for other callers. + extra_pnginfo: + type: object + description: ComfyUI's `extra_pnginfo` for this prompt, which carries the editor's workflow into saved outputs. Used only for jobs sent by Comfy Cloud's editor; ignored for other callers. + additionalProperties: true + metadata: + type: object + description: 'Your own labels for the job, such as which of your customers it ran for. At most 16 pairs. A key is 1 to 40 characters from `A-Z a-z 0-9 _ - .`; a value is a string of at most 256 bytes in UTF-8 (bytes, not characters: 200 `é` is 400 bytes) with no control character (U+0000 to U+001F, tab and newline included, and U+007F to U+009F) and no bidirectional embedding, override or isolate (U+202A to U+202E, U+2066 to U+2069). Anything else is refused `422` `metadata_invalid` and no job is created: a bad key or value is named in the message and in `details.key`, while more than 16 pairs is refused with the count and no `details`. A `metadata` that is not an object is refused `400` `invalid_request`; `null` is the same as none. Stored with the job, returned on it, and filterable on `GET /api/v2/jobs`. Set once, here: nothing changes it later. Only jobs sent to a deployment keep it today: Comfy Cloud refuses a non-empty `metadata` with `422` `metadata_not_supported`, and runs a job with empty or absent `metadata` as before.' + maxProperties: 16 + additionalProperties: + type: string + description: 'At most 256 bytes in UTF-8, which is what the server counts. `maxLength` counts characters, so it is only an upper bound: a shorter value with multi-byte characters can still be refused.' + maxLength: 256 + x-go-type: interface{} + example: + client: acme + batch: '2026-10-05' responses: '201': description: Job created and queued. @@ -427,6 +567,12 @@ paths: application/json: schema: $ref: '#/components/schemas/Job' + '400': + description: '`invalid_request`: `metadata` is not an object. A body that is not JSON at all, or whose `workflow` or `extra_data` is not an object, is a `400` too, answered by the request decoder before any of these checks, with a bare `message` body rather than this envelope.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' '401': $ref: '#/components/responses/Unauthorized' '402': @@ -437,14 +583,20 @@ paths: $ref: '#/components/schemas/ErrorEnvelope' '403': $ref: '#/components/responses/Forbidden' + '404': + description: '`not_found`: on a deployment''s address, no deployment there that the caller''s workspace owns.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' '422': - description: '`invalid_workflow` (with per-node details), `workflow_format_ui`, `missing_asset`, or `idempotency_key_reuse`.' + description: '`invalid_workflow` (with per-node details), `workflow_format_ui`, `missing_asset`, `idempotency_key_reuse`, `metadata_invalid` (for a bad key or value, the message and `details.key` name the key; for more than 16 pairs, the message gives the count and there are no `details`), or `metadata_not_supported` (a surface that keeps no job labels yet).' content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' '429': - description: '`queue_full` (bounded queue depth reached) or, on deployment-scoped surfaces, `deployment_not_ready` (deployment still provisioning/starting). Disambiguate by `error.code`; both mean back off and retry after `Retry-After`.' + description: '`queue_full` (bounded queue depth reached) or, on deployment-scoped surfaces, `deployment_not_ready` (deployment still provisioning/starting) or `deployment_unavailable` (the deployment is ready but its GPU provider is not taking work on it yet), or `rate_limited` (the caller is past a request rate limit). Disambiguate by `error.code`; all four mean back off and retry after `Retry-After`.' headers: Retry-After: $ref: '#/components/headers/RetryAfter' @@ -452,8 +604,124 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + '503': + $ref: '#/components/responses/DeploymentLookupUnavailable' + '500': + $ref: '#/components/responses/UpstreamError' + get: + operationId: listJobs + tags: + - jobs + summary: List jobs, newest first + description: 'Lists jobs newest first. At a deployment''s address, the jobs sent to + + that deployment; at the workspace address, every job in the + + caller''s workspace. A caller never sees another workspace''s jobs. + + + Filter by your own labels with `metadata[]=`, up to 3 + + times; a job is listed only when every pair matches exactly. A 4th + + filter, a repeated key, a key outside the key rule, or a value with a + + character no label may hold (a control character or a bidirectional + + embedding, override or isolate, as on submit) is refused `400` + + `invalid_metadata_filter`. A filter value must be URL-encoded; a raw + + `;` or a bad `%` escape answers `400` `invalid_metadata_filter`. + + + Pages: pass the response''s `next_cursor` back as `cursor`, with the + + same filters, to read the next page. `next_cursor` is absent on the + + last page. Each job appears once across the pages. + + + List items are a job''s stored record, not the `Job` object: they + + carry no `outputs` and no `urls`. Read a job with + + `GET /api/v2/jobs/{id}` for its outputs. + + + Only the serverless platform lists jobs today. Comfy Cloud answers + + `501` `not_implemented`. + + ' + parameters: + - name: limit + in: query + required: false + description: 'Most jobs per page. Above 500 is read as 500; absent means 500. Not a positive integer: `422` `invalid_request`.' + schema: + type: integer + minimum: 1 + - name: cursor + in: query + required: false + description: 'The previous page''s `next_cursor`, unchanged. Opaque: do not build or edit one. A cursor that is not well-formed is refused `400` `invalid_cursor`. Cursors are not signed, so a well-formed one is read as a position whether or not this list issued it.' + schema: + type: string + - name: metadata + in: query + required: false + style: deepObject + explode: true + description: Exact-match label filters, written `metadata[client]=acme`. Up to 3; all must match. + schema: + type: object + maxProperties: 3 + additionalProperties: + type: string + example: + client: acme + responses: + '200': + description: One page of jobs, newest first. + content: + application/json: + schema: + $ref: '#/components/schemas/JobList' + '400': + description: '`invalid_metadata_filter` or `invalid_cursor`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: '`not_found`: on a deployment''s address, no deployment there that the caller''s workspace owns.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '422': + description: '`invalid_request`: `limit` is not a positive integer.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '429': + $ref: '#/components/responses/RateLimited' + '503': + $ref: '#/components/responses/DeploymentLookupUnavailable' '500': $ref: '#/components/responses/UpstreamError' + '501': + description: '`not_implemented`: this surface does not list jobs yet.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' /api/v2/jobs/{id}: get: operationId: getJob @@ -488,6 +756,8 @@ paths: $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' + '503': + $ref: '#/components/responses/DeploymentLookupUnavailable' '500': $ref: '#/components/responses/UpstreamError' /api/v2/jobs/{id}/workflow: @@ -514,6 +784,130 @@ paths: $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' + '503': + $ref: '#/components/responses/DeploymentLookupUnavailable' + '500': + $ref: '#/components/responses/UpstreamError' + /api/v2/jobs/{id}/logs: + get: + operationId: getJobLogs + tags: + - jobs + summary: What the run printed + description: 'Returns the job''s captured execution log. Fetched on demand: a log + + is a debugging artifact a caller wants occasionally, while + + `GET /api/v2/jobs/{id}` is polled to terminal on every run, so the + + log is a resource of its own rather than a field that would ride + + every one of those polls to be read at most once. + + + Captured whenever the worker reports its own outcome, success and + + failure alike, since a job that succeeds while producing the wrong + + thing is exactly what a failure-only log cannot explain. A run the + + platform or the provider killed — out of memory, a crashed worker, a + + timeout, a job past its maximum runtime — never gets that far, so it + + reaches a terminal status carrying no log at all. That is a real gap + + and worth stating: the failures a caller most wants a log for are + + the ones least likely to have produced one. + + + **`204` is the normal answer for a job with no log**, and the cases + + behind it are deliberately not distinguished: this surface does not + + capture logs at all, the job has not finished, the job predates log + + capture, the run was killed before the worker could report one, or + + capture was attempted and failed. + + + Because a `204` never says which of those it is, do not branch on the + + reason — but do note that one of them resolves itself. A job that has + + not finished may have a log once it does, so a caller that wants one + + reads again after a terminal status. A `204` on a job already in a + + terminal state is final, and so is a missing `urls.logs`; both mean + + stop asking. + + + **Only jobs run on the serverless platform** (a + + `{deployment}.run.comfy.app` host) have one today. An implementation + + that captures no logs must still serve this operation, answering + + `204` for every job it can read, so that the two answers stay + + distinct — Comfy Cloud does. A self-hosted deployment on a build + + predating this operation has not implemented it yet and will answer + + a routing `404` instead, which is the case `job.urls.logs` exists to + + keep a client out of: its absence says the surface has no logs at + + all, without a request. + + + Tied to the job''s own retention: this `404`s under the same + + conditions `GET /api/v2/jobs/{id}` does (unknown, not-yours, or past + + its retention deadline). Nothing ages a log out ahead of the job''s + + own `expires_at`, so a job never outlives its log. + + + Live tailing is not offered here yet. When it is, it arrives on this + + same path under `Accept: text/event-stream`, leaving this + + JSON snapshot the default; its resume semantics will be defined + + then, against a capture that is incremental. Until then the SSE + + `log` event on `GET /api/v2/jobs/{id}/events` is the reserved live + + rail, and this is the authoritative snapshot it reconciles against. + + ' + parameters: + - $ref: '#/components/parameters/JobId' + responses: + '200': + description: The captured log. + content: + application/json: + schema: + $ref: '#/components/schemas/JobLogs' + '204': + description: This job has no log. A normal answer, not an error — see the description for the cases it covers. + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/RateLimited' + '503': + $ref: '#/components/responses/DeploymentLookupUnavailable' '500': $ref: '#/components/responses/UpstreamError' /api/v2/jobs/{id}/events: @@ -528,7 +922,9 @@ paths: `progress`, and the most recent `preview` if any), then future - updates. The stream ends after the terminal `status` event. + updates. The stream ends after either the terminal `status` event + + or a terminal `error` event (see the `error` event below). Live push only — NOT a replayable log: events carry no `id`, there @@ -556,9 +952,15 @@ 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, carried while the run is still going. Best-effort diagnostics, and lossy by the same rule as the rest of this stream: lines emitted while a client was disconnected are gone and no `Last-Event-ID` replays them. The authoritative, complete log is the snapshot at `GET /api/v2/jobs/{id}/logs`, which a client re-reads after a terminal status to reconcile whatever it missed — on a surface that captures logs at all. Comfy Cloud does not, and answers `204` there for every job, so this event has nothing to be the live view of; see that operation for what a self-hosted deployment answers. 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 read the snapshot.' x-sse-not-yet-emitted: true schema: '#/components/schemas/LogEvent' + error: + description: 'Terminal event sent when the stream ends for a reason OTHER than the job reaching a terminal state — the credential the stream was opened with stopped being accepted (`credential_expired`, the common case for a short-lived Cloud JWT or OAuth access token that expires while a long job is still running), or the job stopped being accessible (`job_not_found`, `forbidden`). Transient upstream failures (5xx, network errors) do NOT end the stream — they are retried on the next poll. No `status` event follows it. Its whole purpose is to make an aborted stream distinguishable from a completed one: without it both simply close, and a client cannot tell "your job finished" from "you were cut off". On `credential_expired` the job itself is unaffected — refresh the credential and reopen the stream, or fall back to `GET /api/v2/jobs/{id}`. A serverless deployment sends only `job_not_found`. + + + Browser note: a browser `EventSource` also fires a BUILT-IN `error` event on any transport failure, and a server-sent `event: error` frame is delivered to the same listener. The two are told apart by the payload — this event always carries a JSON `ErrorEnvelope` in `data`, the built-in one carries none — so a handler should check for `data` before treating an `error` as an explained, terminal end-of-stream rather than a retryable connection drop.' + schema: '#/components/schemas/ErrorEnvelope' parameters: - $ref: '#/components/parameters/JobId' responses: @@ -576,7 +978,7 @@ paths: '404': $ref: '#/components/responses/NotFound' '429': - description: '`too_many_streams` — the caller already has the maximum number of concurrent GET .../events streams open. Close an existing stream (or wait for one to reach a terminal status) before opening another; GET /api/v2/jobs/{id} remains available as a plain poll regardless of this limit.' + description: '`too_many_streams` — the caller already has the maximum number of concurrent GET .../events streams open. Close an existing stream (or wait for one to reach a terminal status) before opening another; GET /api/v2/jobs/{id} remains available as a plain poll regardless of this limit. Or `rate_limited`, the caller past a request rate limit; retry after `Retry-After`.' headers: Retry-After: $ref: '#/components/headers/RetryAfter' @@ -590,6 +992,8 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + '503': + $ref: '#/components/responses/DeploymentLookupUnavailable' '500': $ref: '#/components/responses/UpstreamError' /api/v2/jobs/{id}/cancel: @@ -600,7 +1004,9 @@ paths: summary: Request cancellation description: 'Requests cancellation and returns the current job object — - `canceling` (interruption takes effect at node/step boundaries) or + `canceling` (interruption takes effect at node/step boundaries), + + `canceled` where the provider''s record already shows the interrupt, or already-terminal. Idempotent: canceling a finished job is a no-op @@ -627,6 +1033,8 @@ paths: $ref: '#/components/responses/NotFound' '429': $ref: '#/components/responses/RateLimited' + '503': + $ref: '#/components/responses/DeploymentLookupUnavailable' '500': $ref: '#/components/responses/UpstreamError' components: @@ -634,7 +1042,7 @@ components: bearerAuth: type: http scheme: bearer - description: '`Authorization: Bearer ` — account-scoped API keys on Cloud and serverless. Self-hosted accepts unauthenticated requests by default and can be configured with a static bearer token.' + description: '`Authorization: Bearer `. The credential is one of: an account-scoped API key (`comfyui-…`), accepted on Cloud and serverless; a Comfy Cloud session JWT; or an OAuth access token issued for the Comfy Cloud resource. Which kinds a given deployment accepts is deployment configuration — an API key always works on Cloud and serverless, and a deployment that does not accept JWT bearers answers `401` with a message saying so. Self-hosted accepts unauthenticated requests by default and can be configured with a static bearer token.' parameters: IdempotencyKey: name: Idempotency-Key @@ -678,7 +1086,7 @@ components: schema: $ref: '#/components/schemas/ErrorEnvelope' Forbidden: - description: '`forbidden` — authenticated but not allowed.' + description: '`forbidden` — authenticated but not allowed. On a serverless deployment, also `sso_required` — the key is valid, but the account must sign in through its organization''s single sign-on, which does not accept this key; it can come back on any operation.' content: application/json: schema: @@ -696,7 +1104,16 @@ components: schema: $ref: '#/components/schemas/ErrorEnvelope' RateLimited: - description: '`rate_limited` — the caller has exceeded the request rate limit for this account. Account/rate-scoped, not job-specific — this can be returned even for a job id the caller doesn''t own or that doesn''t exist, without revealing which.' + description: '`rate_limited` — the caller has exceeded a request rate limit for this account. Account/rate-scoped, not resource-specific — this can be returned even for a job, asset or deployment the caller doesn''t own or that doesn''t exist.' + headers: + Retry-After: + $ref: '#/components/headers/RetryAfter' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + DeploymentLookupUnavailable: + description: 'On deployment-scoped surfaces, `deployment_lookup_unavailable`: the deployment this address names could not be looked up just now. The request was refused before it did anything (nothing was created, read, changed or canceled), so the same request can be sent again after `Retry-After`. Every operation at a deployment''s address can answer this way. A job submit can also answer this 503 with `database_unavailable`: the job could not be saved just now and nothing was started, so the same request can be sent again after `Retry-After`. No other operation answers `database_unavailable`. On Comfy Cloud an asset upload can also answer this 503 with `upload_busy`: the server is busy with other uploads and nothing was stored, so the same upload can be sent again after `Retry-After`.' headers: Retry-After: $ref: '#/components/headers/RetryAfter' @@ -707,7 +1124,7 @@ components: schemas: Asset: type: object - description: 'A user-owned record identified by a server-assigned UUID, backing an immutable blob whose content carries a server-computed blake3 hash. `hash` may be computed lazily: an asset record (and its retrievable bytes) can exist before its hash is filled in.' + description: 'A user-owned record identified by a server-assigned UUID, backing an immutable blob whose content carries a server-computed blake3 hash. `hash` may be computed lazily: an asset record (and its retrievable bytes) can exist before its hash is filled in. On an output, `job_metadata` carries the labels its producing job was submitted with, so code holding only the asset knows which run it came from. It is absent for assets no job produced, for outputs of a job sent without labels, where the serving surface keeps no job labels, and when the labels could not be read: so `job_id` without `job_metadata` does not prove the job had no labels. A create whose file path and bytes match an existing output answers with that output, so its `job_id` and `job_metadata` are the output''s.' required: - id - hash @@ -757,7 +1174,11 @@ components: job_id: type: string nullable: true - description: ID of the job that produced this asset. Absent for uploaded assets, which have no producing job. + description: ID of the job that produced this asset. Absent for assets no job produced, such as a fresh upload. + job_metadata: + allOf: + - $ref: '#/components/schemas/JobMetadata' + description: 'The labels the producing job was submitted with, read through `job_id` on each read. Absent for assets no job produced, for outputs of a job sent without labels, where the serving surface keeps no job labels, when the job is gone, and when the labels could not be read: so `job_id` without `job_metadata` does not prove the job had no labels.' Job: type: object description: One execution of a workflow. Durable from creation until `expires_at`; `outputs` populates incrementally during execution. @@ -821,6 +1242,91 @@ components: execution_ms: 42000 urls: $ref: '#/components/schemas/JobUrls' + deployment_id: + type: string + description: 'The deployment the job was sent to: the id in the address it was posted at, which stays the same when the deployment moves to another release. Absent on a surface that runs jobs on no deployment.' + example: dep-0f19a2b3c4d5 + release_id: + type: string + description: The release of the deployment's build that ran the job, which can differ from the release the deployment runs now. Absent where the serving surface does not report it. + example: 7c1e9a40-3b2d-4f6a-9e81-0c5d2a7b4f13 + release_version: + type: integer + minimum: 1 + description: The version of the release in `release_id`, the number its build cut it as. Absent where the serving surface does not report it or could not look it up at the time of the answer. + example: 3 + metadata: + $ref: '#/components/schemas/JobMetadata' + JobMetadata: + type: object + description: The labels the job was submitted with, exactly as sent. Absent when it was sent with none. + additionalProperties: + type: string + example: + client: acme + JobList: + type: object + description: One page of `GET /api/v2/jobs`. + required: + - jobs + properties: + jobs: + type: array + items: + $ref: '#/components/schemas/JobListItem' + next_cursor: + type: string + description: Pass as `cursor` to read the next page. Absent on the last page. + JobListItem: + type: object + description: A job's stored record. The fields below are stable; an item may carry more, which a client should ignore rather than rely on. + required: + - id + - status + - create_time + - update_time + additionalProperties: true + properties: + id: + type: string + example: 7f3d2c1b-9a8e-4d6f-b012-3c4d5e6f7a8b + status: + $ref: '#/components/schemas/JobStatus' + create_time: + type: string + format: date-time + update_time: + type: string + format: date-time + release_version: + type: integer + minimum: 1 + description: The version of the release that ran the job. Absent where the serving surface does not report it or could not look it up at the time of the answer. + example: 3 + metadata: + $ref: '#/components/schemas/JobMetadata' + JobLogs: + type: object + description: 'A job''s captured execution log — the body of `GET /api/v2/jobs/{id}/logs`. 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 releases of a build. 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 + - complete + properties: + text: + type: string + description: The captured output. + truncated: + type: boolean + description: 'The BEGINNING of the captured output was discarded — `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. This describes the stored log, never the response: it does not mean a caller asked for part of one.' + captured_at: + type: string + format: date-time + description: When the run's output was read back off the worker. + complete: + type: boolean + description: No further output will be appended to this log. Always `true` today, because a log is read back off the worker once, when the run ends, so a log that exists is already whole. Sent so that a surface which later captures output while a run is still going can say so, and a client written now against `false` keeps working when it does. `false` does not promise that more output will arrive, only that this snapshot may not be the last one. 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`. @@ -850,7 +1356,9 @@ components: - expired description: 'Lifecycle: queued → running → succeeded | failed | expired; - a cancel request moves running → canceling → canceled. + a cancel request, or the deletion of the deployment the job is running + + on, moves running → canceling → canceled. Terminal states: succeeded, canceled, failed, expired. @@ -872,6 +1380,12 @@ components: cancel: type: string format: uri-reference + logs: + type: string + format: uri-reference + description: 'Where to read what this run printed. Present on any surface that captures execution logs, which is why it is the one link here that is optional: absent means this surface captures none, for any job, so a client can stop looking without spending a request on an answer it already has. + + Follow this link rather than building the path from the job id. The two are not interchangeable: a surface may be mounted under a prefix this link already carries and a hand-built path would not, and a surface that does not implement the operation at all answers a routing `404` — indistinguishable, to the client, from the `404` that means the job itself is gone. Present does NOT mean this job has a log, and it is deliberately not a signal about one: a surface that captures logs offers the link on every job, including those it will answer `204` for and those whose log it withholds. Read the log, not the link.' Progress: type: object description: Server-computed progress snapshot (node-count and sampler-step weighted). Complete per snapshot — one fully re-syncs a client. @@ -929,6 +1443,7 @@ components: properties: node_id: type: string + description: The workflow node that reported this file; empty when the worker named none. example: '9' name: type: string @@ -982,6 +1497,7 @@ components: example: node_execution_error message: type: string + description: Why the job failed, written for a person to read. On a serverless deployment, where ComfyUI refused the workflow, it names the rejected nodes it has room for and their reasons, which `node_errors` carries in full. Its wording may change; read `code` and `node_errors` rather than matching this text. node_id: type: string nullable: true @@ -991,6 +1507,45 @@ components: traceback: type: string nullable: true + node_errors: + type: object + description: 'Every node ComfyUI rejected when it refused the workflow before running any of it (a value outside its allowed range, a model the deployment does not contain, a graph that loops back on itself), keyed by node id, under the names ComfyUI gave them. Absent when the workflow ran and a node raised: `node_id`, `class_type` and `traceback` describe that failure instead.' + additionalProperties: + $ref: '#/components/schemas/JobNodeError' + example: + '22': + class_type: LoraLoader + errors: + - type: value_not_in_list + message: Value not in list + details: 'lora_name: ''sdxl\Hyper-SDXL-8steps-lora.safetensors'' not in (list of length 40)' + JobNodeError: + type: object + description: 'One node that was rejected, and why: by ComfyUI when it refused the workflow after dispatch, or by the gateway when it refused the workflow at submit.' + required: + - errors + properties: + class_type: + type: string + errors: + type: array + items: + $ref: '#/components/schemas/JobNodeErrorReason' + JobNodeErrorReason: + type: object + description: 'One problem found with a node. `type` is the code for it: ComfyUI''s (for example `value_not_in_list`, `value_bigger_than_max`, `dependency_cycle`), or one of the gateway''s, sent where its submit checks are switched on: `unknown_node_class` (a node class the deployment''s build does not contain), `waits_for_browser` (a node the workflow would run whose class waits for a browser tab to answer while the job runs, which a deployment never has), `invalid_node` (the node is not a JSON object), `invalid_inputs` (its `inputs` is present but not an object, refused only where the deployment''s ComfyUI would fail such a node anyway, so no workflow that runs today is refused), or `invalid_class_type` (its `class_type` is missing or is not a non-empty string). ComfyUI''s `missing_node_type` means the same as `unknown_node_class`, found after dispatch rather than at submit. For either, `message` ends naming the node pack that provides the class where the Comfy node registry knows one. `details` usually starts with the input it is about.' + required: + - type + - message + properties: + type: + type: string + example: value_not_in_list + message: + type: string + example: Value not in list + details: + type: string ErrorEnvelope: type: object description: 'Shared error envelope with machine-readable codes. Core codes (v1): @@ -999,19 +1554,79 @@ components: `missing_asset` (422), `hash_mismatch` (409), `blob_not_found` - (404), `idempotency_key_reuse` (422), + (404), `idempotency_key_reuse` (422), `upload_timed_out` (422: an + + asset upload stopped sending or was too slow; retry from a faster - `queue_full` (429 + Retry-After), `insufficient_credits` (402), + connection), - `not_found` (404), `unauthorized` (401), `forbidden` (403). + `queue_full` (429 + Retry-After), `rate_limited` (429 + Retry-After: + + the caller is past a request rate limit; retry), `insufficient_credits` + + (402), `not_found` (404), `unauthorized` (401), `forbidden` (403). Deployment-scoped surfaces add: `deployment_not_ready` (429 + - Retry-After — the deployment can still reach ready; retry) and + Retry-After — the deployment can still reach ready; retry), + + `deployment_unavailable` (429 + Retry-After: the deployment is ready + + but its GPU provider is not taking work on it yet; retry), + + `deployment_lookup_unavailable` (503 + Retry-After, on any + + operation: the deployment the address names could not be looked up + + just now; nothing was done, so retry), `database_unavailable` (503 + + + Retry-After, only on a job submit: the job could not be saved just + + now and nothing was started, so retry), `deployment_stopped` (422 — terminal deployment state; a retry - cannot succeed without operator action). A 429 is disambiguated + cannot succeed without operator action), `invalid_request` (422: + + a malformed asset upload or asset-from-hash field, or a jobs-list + + `limit` that is not a positive integer), `validation_error` (422, + + Cloud: an asset upload or asset-from-hash field was refused, e.g. a + + tag over the tag limits), `content_blocked` (451: + + content moderation flagged the asset''s bytes) and `sso_required` (403: + + the key is valid, but the account must sign in through its + + organization''s single sign-on, which does not accept this key). + + Comfy Cloud''s asset upload adds `invalid_content_type` (422: the + + request is not `multipart/form-data` with a boundary), + + `invalid_body` (422: a malformed multipart body or upload field), + + `validation_error` (422: the platform refused the upload, for example + + an image over the size cap or a disallowed tag) and `upload_busy` + + (503 + Retry-After: the server is busy with other uploads and nothing + + was stored, so retry). + + Job labels add: `metadata_invalid` (422: a submitted `metadata` breaks a + + limit; `details.key` names the key, except for too many pairs, which + + has no `details`), `metadata_not_supported` (422: + + this surface keeps no job labels yet), `invalid_metadata_filter` (400) + + and `invalid_cursor` (400). A surface that does not list jobs yet + + answers `not_implemented` (501). A 429 is disambiguated by `error.code` alone; clients should treat any 429 + Retry-After @@ -1033,15 +1648,24 @@ components: message: type: string example: 'Node 12 (KSampler): required input ''model'' is not connected' + organization_id: + type: string + description: 'On `sso_required`: the organization whose single sign-on governs this key, the one that holds the account, else the one that holds the key''s workspace. It is the `organization` query parameter of Comfy Cloud''s single sign-on start; treat it as opaque. Absent when the organization is unknown, and on every other code.' + example: org_01HXYZEXAMPLE details: type: object nullable: true additionalProperties: true + description: Machine-readable detail for the code. When it carries `node_errors`, that is keyed by node id and each value is a `JobNodeError`, the same shape as a job's `error.node_errors`, whether the refusal came at submit (for example `unknown_node_class`, a node class the deployment's build does not contain) or from ComfyUI after dispatch. With `unknown_node_class` errors it carries `unknown_node_classes`, the missing classes; with `waits_for_browser` errors, `browser_wait_node_classes`, the classes that wait for a browser. A submit refusal names at most fifty nodes. For `invalid_node`, `invalid_inputs` and `invalid_class_type`, when it found more than fifty, `node_errors_truncated` is true and `malformed_node_count` carries the full count; for `waits_for_browser`, `browser_wait_node_count` does. For `unknown_node_class` it names only nodes whose class is one of the at most ten listed in `unknown_node_classes`; when those nodes number more than fifty, `node_errors_truncated` is true and `unknown_node_count` is how many nodes have a listed class. Nodes whose class is past those ten are neither named nor counted. example: node_errors: '12': - - field: model - reason: missing_input + class_type: SomeCustomNode + errors: + - type: unknown_node_class + message: this deployment's build does not contain the node class SomeCustomNode. + unknown_node_classes: + - SomeCustomNode StatusEvent: type: object description: SSE `status` event payload. diff --git a/src/comfy_low/models/_generated.py b/src/comfy_low/models/_generated.py index c188060..d55ec16 100644 --- a/src/comfy_low/models/_generated.py +++ b/src/comfy_low/models/_generated.py @@ -5,48 +5,31 @@ from enum import Enum from typing import Annotated, Any -from pydantic import AnyUrl, AwareDatetime, BaseModel, Field +from pydantic import AnyUrl, AwareDatetime, BaseModel, ConfigDict, Field -class Asset(BaseModel): +class JobLogs(BaseModel): """ - A user-owned record identified by a server-assigned UUID, backing an immutable blob whose content carries a server-computed blake3 hash. `hash` may be computed lazily: an asset record (and its retrievable bytes) can exist before its hash is filled in. + A job's captured execution log — the body of `GET /api/v2/jobs/{id}/logs`. 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 releases of a build. It is **untrusted text** — a workflow chooses what goes in it — and must be rendered as plain text rather than interpreted. """ - id: Annotated[str, Field(examples=['9f8a1c0d-2b3e-4f56-8a7b-1c2d3e4f5a6b'])] - hash: Annotated[ - str | None, + text: Annotated[str, Field(description='The captured output.')] + truncated: Annotated[ + bool, Field( - description='`blake3:`; null while lazily computed.', - examples=['blake3:9f8a1c0d...'], + description='The BEGINNING of the captured output was discarded — `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. This describes the stored log, never the response: it does not mean a caller asked for part of one.' ), ] - size_bytes: Annotated[int, Field(examples=[4816293])] - content_type: Annotated[str, Field(examples=['image/png'])] - file_path: Annotated[str | None, Field(examples=['photo.png'])] = None - created_new: Annotated[ - bool | None, - Field( - description='On create responses: distinguishes a brand-new blob (true) from a dedup hit against bytes the platform already had (false).' - ), - ] = None - created_at: AwareDatetime - url: Annotated[ - AnyUrl, Field(description='Short-lived content URL (signed, or proxy-served).') + captured_at: Annotated[ + AwareDatetime, + Field(description="When the run's output was read back off the worker."), ] - url_expires_at: AwareDatetime - expires_at: Annotated[ - AwareDatetime | None, - Field( - description="Retention deadline for the asset itself (distinct from `url_expires_at`, the signed URL's validity). Null or absent means the asset is non-expiring. On a dedup-hit create response the deadline may be later than now + the requested/default retention: re-referencing content extends its retention, never shortens it." - ), - ] = None - job_id: Annotated[ - str | None, + complete: Annotated[ + bool, Field( - description='ID of the job that produced this asset. Absent for uploaded assets, which have no producing job.' + description='No further output will be appended to this log. Always `true` today, because a log is read back off the worker once, when the run ends, so a log that exists is already whole. Sent so that a surface which later captures output while a run is still going can say so, and a client written now against `false` keeps working when it does. `false` does not promise that more output will arrive, only that this snapshot may not be the last one.' ), - ] = None + ] class Format(Enum): @@ -78,7 +61,8 @@ class JobWorkflowResponse(BaseModel): class JobStatus(Enum): """ Lifecycle: queued → running → succeeded | failed | expired; - a cancel request moves running → canceling → canceled. + a cancel request, or the deletion of the deployment the job is running + on, moves running → canceling → canceled. Terminal states: succeeded, canceled, failed, expired. """ @@ -100,6 +84,12 @@ class JobUrls(BaseModel): self: str events: str cancel: str + logs: Annotated[ + str | None, + Field( + description='Where to read what this run printed. Present on any surface that captures execution logs, which is why it is the one link here that is optional: absent means this surface captures none, for any job, so a client can stop looking without spending a request on an answer it already has.\nFollow this link rather than building the path from the job id. The two are not interchangeable: a surface may be mounted under a prefix this link already carries and a hand-built path would not, and a surface that does not implement the operation at all answers a routing `404` — indistinguishable, to the client, from the `404` that means the job itself is gone. Present does NOT mean this job has a log, and it is deliberately not a signal about one: a surface that captures logs offers the link on every job, including those it will answer `204` for and those whose log it withholds. Read the log, not the link.' + ), + ] = None class Progress(BaseModel): @@ -138,16 +128,14 @@ class OutputType(Enum): latent = 'latent' -class JobError(BaseModel): +class JobNodeErrorReason(BaseModel): """ - Execution failure detail, carried in `job.error` (not an HTTP error). + One problem found with a node. `type` is the code for it: ComfyUI's (for example `value_not_in_list`, `value_bigger_than_max`, `dependency_cycle`), or one of the gateway's, sent where its submit checks are switched on: `unknown_node_class` (a node class the deployment's build does not contain), `waits_for_browser` (a node the workflow would run whose class waits for a browser tab to answer while the job runs, which a deployment never has), `invalid_node` (the node is not a JSON object), `invalid_inputs` (its `inputs` is present but not an object, refused only where the deployment's ComfyUI would fail such a node anyway, so no workflow that runs today is refused), or `invalid_class_type` (its `class_type` is missing or is not a non-empty string). ComfyUI's `missing_node_type` means the same as `unknown_node_class`, found after dispatch rather than at submit. For either, `message` ends naming the node pack that provides the class where the Comfy node registry knows one. `details` usually starts with the input it is about. """ - code: Annotated[str, Field(examples=['node_execution_error'])] - message: str - node_id: str | None = None - class_type: str | None = None - traceback: str | None = None + type: Annotated[str, Field(examples=['value_not_in_list'])] + message: Annotated[str, Field(examples=['Value not in list'])] + details: str | None = None class Error(BaseModel): @@ -156,12 +144,33 @@ class Error(BaseModel): str, Field(examples=["Node 12 (KSampler): required input 'model' is not connected"]), ] + organization_id: Annotated[ + str | None, + Field( + description="On `sso_required`: the organization whose single sign-on governs this key, the one that holds the account, else the one that holds the key's workspace. It is the `organization` query parameter of Comfy Cloud's single sign-on start; treat it as opaque. Absent when the organization is unknown, and on every other code.", + examples=['org_01HXYZEXAMPLE'], + ), + ] = None details: Annotated[ dict[str, Any] | None, Field( + description="Machine-readable detail for the code. When it carries `node_errors`, that is keyed by node id and each value is a `JobNodeError`, the same shape as a job's `error.node_errors`, whether the refusal came at submit (for example `unknown_node_class`, a node class the deployment's build does not contain) or from ComfyUI after dispatch. With `unknown_node_class` errors it carries `unknown_node_classes`, the missing classes; with `waits_for_browser` errors, `browser_wait_node_classes`, the classes that wait for a browser. A submit refusal names at most fifty nodes. For `invalid_node`, `invalid_inputs` and `invalid_class_type`, when it found more than fifty, `node_errors_truncated` is true and `malformed_node_count` carries the full count; for `waits_for_browser`, `browser_wait_node_count` does. For `unknown_node_class` it names only nodes whose class is one of the at most ten listed in `unknown_node_classes`; when those nodes number more than fifty, `node_errors_truncated` is true and `unknown_node_count` is how many nodes have a listed class. Nodes whose class is past those ten are neither named nor counted.", examples=[ - {'node_errors': {'12': [{'field': 'model', 'reason': 'missing_input'}]}} - ] + { + 'node_errors': { + '12': { + 'class_type': 'SomeCustomNode', + 'errors': [ + { + 'type': 'unknown_node_class', + 'message': "this deployment's build does not contain the node class SomeCustomNode.", + } + ], + } + }, + 'unknown_node_classes': ['SomeCustomNode'], + } + ], ), ] = None @@ -171,13 +180,43 @@ class ErrorEnvelope(BaseModel): Shared error envelope with machine-readable codes. Core codes (v1): `invalid_workflow` (422), `workflow_format_ui` (422), `missing_asset` (422), `hash_mismatch` (409), `blob_not_found` - (404), `idempotency_key_reuse` (422), - `queue_full` (429 + Retry-After), `insufficient_credits` (402), - `not_found` (404), `unauthorized` (401), `forbidden` (403). + (404), `idempotency_key_reuse` (422), `upload_timed_out` (422: an + asset upload stopped sending or was too slow; retry from a faster + connection), + `queue_full` (429 + Retry-After), `rate_limited` (429 + Retry-After: + the caller is past a request rate limit; retry), `insufficient_credits` + (402), `not_found` (404), `unauthorized` (401), `forbidden` (403). Deployment-scoped surfaces add: `deployment_not_ready` (429 + - Retry-After — the deployment can still reach ready; retry) and + Retry-After — the deployment can still reach ready; retry), + `deployment_unavailable` (429 + Retry-After: the deployment is ready + but its GPU provider is not taking work on it yet; retry), + `deployment_lookup_unavailable` (503 + Retry-After, on any + operation: the deployment the address names could not be looked up + just now; nothing was done, so retry), `database_unavailable` (503 + + Retry-After, only on a job submit: the job could not be saved just + now and nothing was started, so retry), `deployment_stopped` (422 — terminal deployment state; a retry - cannot succeed without operator action). A 429 is disambiguated + cannot succeed without operator action), `invalid_request` (422: + a malformed asset upload or asset-from-hash field, or a jobs-list + `limit` that is not a positive integer), `validation_error` (422, + Cloud: an asset upload or asset-from-hash field was refused, e.g. a + tag over the tag limits), `content_blocked` (451: + content moderation flagged the asset's bytes) and `sso_required` (403: + the key is valid, but the account must sign in through its + organization's single sign-on, which does not accept this key). + Comfy Cloud's asset upload adds `invalid_content_type` (422: the + request is not `multipart/form-data` with a boundary), + `invalid_body` (422: a malformed multipart body or upload field), + `validation_error` (422: the platform refused the upload, for example + an image over the size cap or a disallowed tag) and `upload_busy` + (503 + Retry-After: the server is busy with other uploads and nothing + was stored, so retry). + Job labels add: `metadata_invalid` (422: a submitted `metadata` breaks a + limit; `details.key` names the key, except for too many pairs, which + has no `details`), `metadata_not_supported` (422: + this surface keeps no job labels yet), `invalid_metadata_filter` (400) + and `invalid_cursor` (400). A surface that does not list jobs yet + answers `not_implemented` (501). A 429 is disambiguated by `error.code` alone; clients should treat any 429 + Retry-After as "back off and retry". @@ -246,12 +285,88 @@ class AssetReference(BaseModel): info: Info +class Asset(BaseModel): + """ + A user-owned record identified by a server-assigned UUID, backing an immutable blob whose content carries a server-computed blake3 hash. `hash` may be computed lazily: an asset record (and its retrievable bytes) can exist before its hash is filled in. On an output, `job_metadata` carries the labels its producing job was submitted with, so code holding only the asset knows which run it came from. It is absent for assets no job produced, for outputs of a job sent without labels, where the serving surface keeps no job labels, and when the labels could not be read: so `job_id` without `job_metadata` does not prove the job had no labels. A create whose file path and bytes match an existing output answers with that output, so its `job_id` and `job_metadata` are the output's. + """ + + id: Annotated[str, Field(examples=['9f8a1c0d-2b3e-4f56-8a7b-1c2d3e4f5a6b'])] + hash: Annotated[ + str | None, + Field( + description='`blake3:`; null while lazily computed.', + examples=['blake3:9f8a1c0d...'], + ), + ] + size_bytes: Annotated[int, Field(examples=[4816293])] + content_type: Annotated[str, Field(examples=['image/png'])] + file_path: Annotated[str | None, Field(examples=['photo.png'])] = None + created_new: Annotated[ + bool | None, + Field( + description='On create responses: distinguishes a brand-new blob (true) from a dedup hit against bytes the platform already had (false).' + ), + ] = None + created_at: AwareDatetime + url: Annotated[ + AnyUrl, Field(description='Short-lived content URL (signed, or proxy-served).') + ] + url_expires_at: AwareDatetime + expires_at: Annotated[ + AwareDatetime | None, + Field( + description="Retention deadline for the asset itself (distinct from `url_expires_at`, the signed URL's validity). Null or absent means the asset is non-expiring. On a dedup-hit create response the deadline may be later than now + the requested/default retention: re-referencing content extends its retention, never shortens it." + ), + ] = None + job_id: Annotated[ + str | None, + Field( + description='ID of the job that produced this asset. Absent for assets no job produced, such as a fresh upload.' + ), + ] = None + job_metadata: Annotated[ + dict[str, str] | None, + Field( + description='The labels the producing job was submitted with, read through `job_id` on each read. Absent for assets no job produced, for outputs of a job sent without labels, where the serving surface keeps no job labels, when the job is gone, and when the labels could not be read: so `job_id` without `job_metadata` does not prove the job had no labels.' + ), + ] = None + + +class JobListItem(BaseModel): + """ + A job's stored record. The fields below are stable; an item may carry more, which a client should ignore rather than rely on. + """ + + model_config = ConfigDict( + extra='allow', + ) + id: Annotated[str, Field(examples=['7f3d2c1b-9a8e-4d6f-b012-3c4d5e6f7a8b'])] + status: JobStatus + create_time: AwareDatetime + update_time: AwareDatetime + release_version: Annotated[ + int | None, + Field( + description='The version of the release that ran the job. Absent where the serving surface does not report it or could not look it up at the time of the answer.', + examples=[3], + ge=1, + ), + ] = None + metadata: dict[str, str] | None = None + + class Output(BaseModel): """ A committed job output. Outputs are assets: `id` is the asset UUID, retrievable via GET /api/v2/assets/{id} for as long as the job is retained. `hash` is lazily computed and may be null on the retrieval hot path. """ - node_id: Annotated[str, Field(examples=['9'])] + node_id: Annotated[ + str, + Field( + description='The workflow node that reported this file; empty when the worker named none.', + examples=['9'], + ), + ] name: Annotated[str, Field(examples=['ComfyUI_00001_.png'])] type: OutputType content_type: Annotated[str, Field(examples=['image/png'])] @@ -269,6 +384,66 @@ class Output(BaseModel): ] = None +class JobNodeError(BaseModel): + """ + One node that was rejected, and why: by ComfyUI when it refused the workflow after dispatch, or by the gateway when it refused the workflow at submit. + """ + + class_type: str | None = None + errors: list[JobNodeErrorReason] + + +class JobList(BaseModel): + """ + One page of `GET /api/v2/jobs`. + """ + + jobs: list[JobListItem] + next_cursor: Annotated[ + str | None, + Field( + description='Pass as `cursor` to read the next page. Absent on the last page.' + ), + ] = None + + +class JobError(BaseModel): + """ + Execution failure detail, carried in `job.error` (not an HTTP error). + """ + + code: Annotated[str, Field(examples=['node_execution_error'])] + message: Annotated[ + str, + Field( + description='Why the job failed, written for a person to read. On a serverless deployment, where ComfyUI refused the workflow, it names the rejected nodes it has room for and their reasons, which `node_errors` carries in full. Its wording may change; read `code` and `node_errors` rather than matching this text.' + ), + ] + node_id: str | None = None + class_type: str | None = None + traceback: str | None = None + node_errors: Annotated[ + dict[str, JobNodeError] | None, + Field( + description='Every node ComfyUI rejected when it refused the workflow before running any of it (a value outside its allowed range, a model the deployment does not contain, a graph that loops back on itself), keyed by node id, under the names ComfyUI gave them. Absent when the workflow ran and a node raised: `node_id`, `class_type` and `traceback` describe that failure instead.', + examples=[ + { + '22': { + 'class_type': 'LoraLoader', + 'errors': [ + { + 'type': 'value_not_in_list', + 'message': 'Value not in list', + 'details': "lora_name: 'sdxl\\Hyper-SDXL-8steps-lora.safetensors' not in (list of length 40)", + } + ], + } + } + ], + ), + ] = None + + class Job(BaseModel): """ One execution of a workflow. Durable from creation until `expires_at`; `outputs` populates incrementally during execution. @@ -302,3 +477,26 @@ class Job(BaseModel): ), ] = None urls: JobUrls + deployment_id: Annotated[ + str | None, + Field( + description='The deployment the job was sent to: the id in the address it was posted at, which stays the same when the deployment moves to another release. Absent on a surface that runs jobs on no deployment.', + examples=['dep-0f19a2b3c4d5'], + ), + ] = None + release_id: Annotated[ + str | None, + Field( + description="The release of the deployment's build that ran the job, which can differ from the release the deployment runs now. Absent where the serving surface does not report it.", + examples=['7c1e9a40-3b2d-4f6a-9e81-0c5d2a7b4f13'], + ), + ] = None + release_version: Annotated[ + int | None, + Field( + description='The version of the release in `release_id`, the number its build cut it as. Absent where the serving surface does not report it or could not look it up at the time of the answer.', + examples=[3], + ge=1, + ), + ] = None + metadata: dict[str, str] | None = None