> ## Documentation Index
> Fetch the complete documentation index at: https://docs.comfy.org/llms.txt
> Use this file to discover all available pages before exploring further.

# 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.
The path is the SAME canonical model ID that `post` above invokes, on the same path template: one string addresses both routes, so there is no second ID alphabet to keep in step. `provider` and `model` are governed by the same `RouterProvider` / `RouterModel` parameters, with the same alphabet. That template is TWO segments; a catalog `id` carrying a third `variant` segment addresses neither route, because how a variant is addressed over HTTP is not settled by this contract (see `RouterProvider` and `RouterModelListEntry.id`).
The `200` body is a SUPERSET of the entry `GET /v2/models` returns for the same model: `RouterModelDetail` composes the list entry `RouterModelListEntry` by reference rather than restating its fields, so the listing and the detail cannot drift into two shapes for one model.
An ID that resolves to no model returns `404` with `error_type: model_not_found` - the same Router-owned body, the same bucket on `X-Comfy-Error-Type`, and the same fuzzy `detail` suggestions the invocation route returns for that ID. Both routes resolve through one catalog, so they cannot disagree about the same typo. Those suggestions are drawn from the models the CALLER is entitled to see: a miss on an unauthenticated or unentitled ID must not name models the caller could not otherwise enumerate. This is a requirement on the handler story (see `drip/codegen.yaml`), not something this contract can enforce.
The read sits behind the SAME enablement gate as `post` above, consulted before the catalog is touched: a caller Comfy Router is not switched on for gets `403` with `error_type: not_enabled` and learns nothing about which models exist - not even through a `404`'s suggestions - and a gate that could not be evaluated answers `503` with `error_type: service_unavailable`. Both carry the Router-owned body and the bucket on `X-Comfy-Error-Type`, exactly as on `post`.
The entry is cacheable by the caller. It carries a strong `ETag` over the entry bytes and honours `If-None-Match` with a `304`.
This operation is deliberately tagged `Comfy Router` and NOT `API Nodes`, for the reason given on `post` above.



## OpenAPI

````yaml https://api.comfy.org/openapi get /v2/models/{provider}/{model}
openapi: 3.0.2
info:
  title: Comfy API
  version: '1.0'
servers:
  - url: https://api.comfy.org
security: []
tags:
  - description: Comfy Router's canonical, model-ID-addressed routes.
    name: Comfy Router
  - description: >-
      Signed-in users running Comfy-owned serverless apps with free runs and
      flat or per-second pricing (FE-2737).
    name: App Proxy
paths:
  /v2/models/{provider}/{model}:
    get:
      tags:
        - Comfy Router
      summary: Read one partner model's catalog entry by canonical model ID.
      description: >-
        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.

        The path is the SAME canonical model ID that `post` above invokes, on
        the same path template: one string addresses both routes, so there is no
        second ID alphabet to keep in step. `provider` and `model` are governed
        by the same `RouterProvider` / `RouterModel` parameters, with the same
        alphabet. That template is TWO segments; a catalog `id` carrying a third
        `variant` segment addresses neither route, because how a variant is
        addressed over HTTP is not settled by this contract (see
        `RouterProvider` and `RouterModelListEntry.id`).

        The `200` body is a SUPERSET of the entry `GET /v2/models` returns for
        the same model: `RouterModelDetail` composes the list entry
        `RouterModelListEntry` by reference rather than restating its fields, so
        the listing and the detail cannot drift into two shapes for one model.

        An ID that resolves to no model returns `404` with `error_type:
        model_not_found` - the same Router-owned body, the same bucket on
        `X-Comfy-Error-Type`, and the same fuzzy `detail` suggestions the
        invocation route returns for that ID. Both routes resolve through one
        catalog, so they cannot disagree about the same typo. Those suggestions
        are drawn from the models the CALLER is entitled to see: a miss on an
        unauthenticated or unentitled ID must not name models the caller could
        not otherwise enumerate. This is a requirement on the handler story (see
        `drip/codegen.yaml`), not something this contract can enforce.

        The read sits behind the SAME enablement gate as `post` above, consulted
        before the catalog is touched: a caller Comfy Router is not switched on
        for gets `403` with `error_type: not_enabled` and learns nothing about
        which models exist - not even through a `404`'s suggestions - and a gate
        that could not be evaluated answers `503` with `error_type:
        service_unavailable`. Both carry the Router-owned body and the bucket on
        `X-Comfy-Error-Type`, exactly as on `post`.

        The entry is cacheable by the caller. It carries a strong `ETag` over
        the entry bytes and honours `If-None-Match` with a `304`.

        This operation is deliberately tagged `Comfy Router` and NOT `API
        Nodes`, for the reason given on `post` above.
      operationId: GetRouterModel
      parameters:
        - $ref: '#/components/parameters/RouterProvider'
        - $ref: '#/components/parameters/RouterModel'
        - description: >-
            The `ETag` a caller holds from an earlier `200`. When it matches the
            current entry (RFC 9110 weak comparison; `*` matches any current
            entry) the answer is a bodyless `304` carrying the same `ETag`,
            otherwise the full entry.
          in: header
          name: If-None-Match
          schema:
            type: string
      responses:
        '200':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RouterModelDetail'
          description: OK - the model's catalog entry.
          headers:
            Cache-Control:
              $ref: '#/components/headers/RouterCatalogCacheControlHeader'
            ETag:
              $ref: '#/components/headers/RouterCatalogETagHeader'
            X-Comfy-Request-Id:
              $ref: '#/components/headers/RouterRequestIdHeader'
        '304':
          description: >-
            Not Modified - the entry is unchanged since the `ETag` the caller
            sent in `If-None-Match`. No body is returned.
          headers:
            Cache-Control:
              $ref: '#/components/headers/RouterCatalogCacheControlHeader'
            ETag:
              $ref: '#/components/headers/RouterCatalogETagHeader'
            X-Comfy-Request-Id:
              $ref: '#/components/headers/RouterRequestIdHeader'
        '401':
          $ref: '#/components/responses/RouterRequestError'
        '403':
          $ref: '#/components/responses/RouterRequestError'
        '404':
          $ref: '#/components/responses/RouterRequestError'
        '503':
          $ref: '#/components/responses/RouterRequestError'
      security:
        - BearerAuth: []
        - ApiKeyAuth: []
components:
  parameters:
    RouterProvider:
      description: >-
        Lowercase provider segment of the canonical `{provider}/{model}` model
        ID - the partner whose model is being run.

        The schema is `RouterProviderSegment`, the SAME component a catalog
        entry's `provider` field references, so an ID `GET /v2/models` lists
        cannot drift from the ids this route accepts. Its `pattern` is a
        CONTRACT statement, not enforcement: comfy-api installs no OpenAPI
        request validator and oapi-codegen binds path parameters as plain
        strings, so the handler must re-validate this segment itself before
        using it to select a provider or compose an upstream URL.
      in: path
      name: provider
      required: true
      schema:
        $ref: '#/components/schemas/RouterProviderSegment'
    RouterModel:
      description: >-
        Lowercase model segment of the canonical `{provider}/{model}` model ID -
        the model to run within that provider.

        As with `provider`, the schema is the shared `RouterModelSegment`
        component and its `pattern` documents the contract rather than enforcing
        it - see `RouterProvider`.
      in: path
      name: model
      required: true
      schema:
        $ref: '#/components/schemas/RouterModelSegment'
  schemas:
    RouterModelDetail:
      allOf:
        - $ref: '#/components/schemas/RouterModelListEntry'
        - $ref: '#/components/schemas/RouterModelDetailFields'
      description: >-
        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.

        It is `allOf: [RouterModelListEntry, RouterModelDetailFields]` so that
        the shared half IS the list entry rather than a copy of it - see the
        section comment above. A generated client can therefore treat a detail
        response as a list entry everywhere a list entry is expected.
      type: object
    RouterProviderSegment:
      description: >-
        Lowercase `provider` segment of the canonical `{provider}/{model}` 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.

        The `pattern` is a CONTRACT statement, not enforcement: comfy-api
        installs no OpenAPI request validator and oapi-codegen binds path
        parameters as plain strings, so a handler must re-validate the segment
        itself before using it to select a provider or compose an upstream URL.
        The alphabet deliberately admits no `/`, no percent-encoding, and no
        repeated separator, so no accepted value can contain a `.` or `..` path
        segment.
      example: bfl
      maxLength: 64
      pattern: ^[a-z0-9]+([._-][a-z0-9]+)*$
      type: string
    RouterModelSegment:
      description: >-
        Lowercase `model` segment of the canonical `{provider}/{model}` 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`.

        As with the provider segment, the `pattern` documents the contract and
        does not enforce it. Dots are permitted inside the segment because
        partner model IDs use them for versions (`flux-pro-1.1`), but a repeated
        separator is not, so `..` cannot appear.
      example: flux-2-pro
      maxLength: 128
      pattern: ^[a-z0-9]+([._-][a-z0-9]+)*$
      type: string
    RouterModelListEntry:
      description: >-
        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.

        `billing` is the one non-identity member, and it is here rather than on
        the detail route deliberately: it exists so a caller can branch BEFORE
        invoking, and the listing is the payload an SDK already has in hand at
        that moment. Pushing it to the detail route would mean one extra round
        trip per model to answer a question asked about every model.
      properties:
        billing:
          $ref: '#/components/schemas/RouterModelBilling'
        id:
          $ref: '#/components/schemas/RouterModelId'
        model:
          $ref: '#/components/schemas/RouterModelSegment'
        provider:
          $ref: '#/components/schemas/RouterProviderSegment'
        schema_etag:
          description: >-
            The current strong `ETag` of this model's `GET
            /v2/models/{provider}/{model}/openapi.json` document. Send it as
            `If-None-Match` on that read to get a `304` while the schema is
            unchanged, or compare it with the value seen last time to learn
            which models' schemas changed without fetching every document.
            Absent whenever that read would not answer `200` for this caller at
            this moment: the schema service is not available, the model's
            document cannot be rendered, or the model stopped being available to
            this caller while the response was being built. An absent tag is not
            a schema change; re-read the model's document to learn its current
            tag.
          example: '"9f2c1e0b7a4d3c6e8f5a2b1d0c9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e"'
          type: string
      required:
        - id
        - provider
        - model
        - billing
      type: object
    RouterModelDetailFields:
      description: >-
        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.

        Every property here is OPTIONAL, and each is owned by a sibling E3
        story. This contract reserves the field name and its type; the story
        that lands the feature narrows the description and the value set. A
        model whose sibling story has not landed for it yet simply omits the
        field, which is why nothing here is `required` and why a client must
        treat every one of them as absent-by-default.

        The `billing_policy` slot this block also reserved is gone: E3 S3.5
        landed as `RouterModelListEntry.billing`, on the SHARED half, because
        the field has to be readable from the listing and not only from the
        detail - see that schema.
      properties:
        input_schema_url:
          description: >-
            Pointer to this model's input schema document - the description of
            the body `POST /v2/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.

            It is an ABSOLUTE `https` URI, deliberately not a `uri-reference`.
            Clients dereference or render this value, and a relative reference
            would resolve against the caller's own base while a `file:`, `data:`
            or `javascript:` scheme would be scheme confusion in every generated
            SDK. If this field is ever populated from partner- or admin-supplied
            metadata, that constraint is the only thing standing between that
            metadata and the client, so a server MUST NOT emit a value this
            pattern rejects.
          format: uri
          maxLength: 2048
          pattern: ^https://
          type: string
      type: object
    RouterErrorResponse:
      description: >-
        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.
      properties:
        detail:
          description: >-
            Human-readable description of the failure, safe to surface to an end
            user. Not machine-parsed - branch on `error_type` instead. For
            `model_not_found` on a retired model, `detail` reads `<id> was
            retired on <YYYY-MM-DD>; use <successor>` when a successor is
            recorded and visible to the caller, or just `<id> was retired on
            <YYYY-MM-DD>` when it is not, instead of the usual suggestion list.
            A successor named here is always a live catalog ID the call can be
            re-pointed at. A model whose sunset date is still in the future is
            not retired: it stays listed by `GET /v2/models` and stays runnable
            until that date. Like the rest of `detail`, this retirement wording
            is for display and carries no compatibility guarantee.

            Two documented exceptions to the not-machine-parsed rule, because
            `error_type` cannot express either: on `POST
            /v2/models/{provider}/{model}` the `409` is answered for three
            unrelated conditions that share the `invalid_input` bucket, and only
            `detail` separates them. A `detail` beginning `this request resolved
            a BYOK credential` is the BYOK `model_provider` refusal; a `detail`
            beginning "this request's `image` selects the edit operation" is the
            multipart `model_provider` refusal; neither clears with a new
            `Idempotency-Key`. Any other `invalid_input` `409` on that route is
            the key conflict. Both prefixes are stable contract terms and may be
            matched. Nowhere else does branching on `detail` carry a
            compatibility guarantee.
          type: string
        error_type:
          $ref: '#/components/schemas/RouterErrorType'
        refusal_subject:
          description: >-
            Which input or output a content-policy refusal was about, as a
            Router-level closed vocabulary: `input`, `output`, `input_text`,
            `input_image`, `input_video`, `input_audio`, `output_text`,
            `output_image`, `output_video`, `output_audio`. The bare `input` /
            `output` values name the side when the provider did not name a
            modality. Present only when `error_type` is
            `content_policy_violation` and the provider named the refused
            subject with a machine-readable code; absent otherwise. Never
            provider text. Named today for BytePlus, Runway, BFL, Gemini, Veo,
            Vertex, xAI and Wan refusals, and for the MiniMax Router's Nano
            Banana legs' Gemini refusals; a provider whose refusal does not say
            which side it was about leaves it absent. It mirrors the
            `X-Comfy-Refusal-Subject` header.
          type: string
        upstream_detail:
          description: >-
            A bounded, sanitized reason the model provider gave for rejecting
            the request, present only when `error_type` is `invalid_input` and
            `X-Comfy-Upstream-Status` is a provider `4xx` or `2xx` - i.e. the
            provider refused the request as malformed and said why. Usually that
            status is a `4xx`; it is a `2xx` for a provider that reports a
            rejected generation inside a success envelope (a BytePlus
            failed-task poll is HTTP `200` with the reason in its body). Absent
            on every other failure, including provider `5xx`, transport
            failures, content-policy refusals and any refusal Router raised
            about itself. It mirrors the `X-Comfy-Upstream-Detail` header.

            Unlike `detail`, this is provider-authored text, so it is
            length-capped and stripped of URLs, hostnames, emails, tokens and
            id-shaped values before it is surfaced; it is never the raw provider
            body. It is a human-readable hint, not machine-parsed - branch on
            `error_type` instead.
          type: string
      required:
        - detail
        - error_type
      type: object
    RouterModelBilling:
      description: >-
        Per-model billing facts a caller needs before invoking - not prices.
        Usage and cost figures never appear here.

        It is an object with one member rather than a flat sibling field because
        more pre-invocation billing facts are coming and a flat
        `billing_charges_*` family would have to be un-flattened later; a nested
        object absorbs them without a breaking rename. The member is `required`
        for the reason its own description gives - "we did not say" and "we do
        not charge" must not be the same wire state.
      properties:
        charges_on_policy_rejection:
          $ref: '#/components/schemas/RouterChargesOnPolicyRejection'
      required:
        - charges_on_policy_rejection
      type: object
    RouterModelId:
      description: >-
        A canonical Comfy Router model ID, `{provider}/{model}` - exactly the
        value that addresses the model on `POST /v2/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.

        TestRouterCatalogIdsMatchInvocationRoute probes all three patterns
        behaviourally against one corpus, so loosening or tightening any of them
        alone fails CI rather than silently letting the catalog advertise an ID
        the invocation route would reject. The optional `variant` third segment
        is deliberately absent: how a variant is addressed is not settled by the
        invocation contract, so the catalog must not list an ID that route is
        not yet defined to accept. The PUBLIC lead paragraphs of
        `RouterProviderSegment`, `RouterModelSegment` and the `RouterProvider` /
        `RouterModel` path parameters therefore spell the canonical ID
        `{provider}/{model}` with no variant suffix, and must keep doing so
        while the variant form is unsettled: those leads are the only text the
        SDK projection publishes, so a three-segment form there vendors an ID
        shape every published `pattern` rejects and no path template accepts AS
        A MODEL ID. (`/v2/models/{provider}/{model}/openapi.json` does match a
        third segment, but only when it is that one literal - which is exactly
        the collision `getRouterModelInputSchema` reserves against, not an ID
        the route accepts.) The reservation is argued in the rationale
        paragraphs, which are never projected, and that is where it belongs. ONE
        bound does not survive the composition: `maxLength` here is the TOTAL,
        so a 193-character ID made of a 100-character provider and a
        92-character model satisfies this schema while its provider segment
        exceeds `RouterProviderSegment`'s own 64. A single `pattern` cannot
        express a per-segment length bound - the structural alphabet and a
        character count are not jointly expressible without lookahead, which
        this repo's Go-side pattern probes cannot compile. The per-segment
        bounds are therefore carried by the sibling `provider` and `model`
        fields of `RouterModelListEntry`, which reference the bounded segment
        schemas directly, so no CONFORMING entry can carry such an `id`.
        TestRouterCatalogIdsMatchInvocationRoute pins that this residual is
        length-only: over-length segments are in its corpus, and any divergence
        that is not purely a per-segment length overrun fails.
      example: bfl/flux-2-pro
      maxLength: 193
      pattern: ^[a-z0-9]+([._-][a-z0-9]+)*/[a-z0-9]+([._-][a-z0-9]+)*$
      type: string
    RouterErrorType:
      description: >-
        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 nineteen 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`, `service_unavailable`,
        `rate_limited`, `cancelled`, `queue_timeout`, `request_not_found` and
        `queue_backlog_full`. Closed describes the set as documented today, not
        a bound that holds forever: the set is expected to grow, which is why
        this is deliberately a plain string and not an `enum`, so a client must
        treat an unrecognised value as `internal_error` rather than switch
        exhaustively over the list above and break on the next addition.

        RETRY SEMANTICS. `not_enabled` is TERMINAL - the same request will be
        refused the same way, so a client must not retry it - while
        `service_unavailable` is TRANSIENT and SHOULD be retried with backoff.
        That opposition is the whole reason they are two buckets rather than
        one: a retry policy keys off the status/`error_type` pair, and a single
        shared bucket would make retry wrong in one direction or the other.

        `not_enabled` and `forbidden` both return `403` and, like the `504` pair
        below, the difference between them is the one the status cannot carry.
        `forbidden` means the credential is valid but is not entitled to this
        model or operation - a decision about the caller. `not_enabled` means
        Comfy Router is not switched on for the caller yet - a state of the
        product rollout, not a judgement about them, and one that changes
        without the caller doing anything. `404` would have claimed the model
        does not exist when it does, and a `5xx` would have blamed the server
        for a deliberate state, so neither was available as a way to separate
        the two by status instead.

        HOW OFTEN `not_enabled` IS SEEN CHANGES OVER TIME, AND IT NEVER STOPS
        BEING VALID. While Comfy Router is rolling out, most callers are not on
        the ramp yet and this is the ordinary answer for them. Once Router is
        fully rolled out it becomes rare. It does not become impossible: the
        deploy-time switch that turns Router off for an environment is a
        permanent operational lever, so `not_enabled` is still the correct
        answer the day it is pulled. It is therefore a value Comfy expects to
        STOP EMITTING often rather than one that is ever withdrawn - a published
        bucket is never removed, because removing it would delete the generated
        exception class an SDK built from it and break any client branching on
        it. `service_unavailable` has no such arc at all: a dependency can
        always fault.

        `deadline_exceeded` and `provider_timeout` both return `504` and the
        difference between them is which SIDE ran out of time, which is not
        cosmetic: `provider_timeout` means the upstream model provider ran out
        of time, while `deadline_exceeded` means COMFY stopped holding the
        connection at its own configured bound. A client that collapses the two
        onto the status alone loses that distinction, and with it the difference
        between "the partner is failing" and "the call is longer than the
        connection Comfy will hold".

        NEITHER BUCKET IS A STATEMENT ABOUT THE CHARGE. Comfy's charges settle
        on COMPLETION: a generation the provider completed is billed whether or
        not the caller was still connected to receive the response, and a
        generation that failed or never completed is not billed. Reaching
        `deadline_exceeded` or `client_disconnected` therefore does not tell a
        caller they were not charged.

        `cancelled` IS THE SAME KIND OF VALUE and it is the one most often read
        as a refund. Cancelling asks Comfy to stop waiting; it does not stop a
        partner that has already accepted the generation, so a request cancelled
        while it was still `IN_QUEUE` was never dispatched and cannot be
        charged, whereas one cancelled after it was admitted may still settle a
        charge. The bucket names what happened to the REQUEST, never what
        happened to the bill.

        It is deliberately a plain string rather than an `enum`: the set is
        expected to grow -- it already has, most recently with the
        queued-delivery buckets `cancelled`, `queue_timeout` and
        `request_not_found`, and `file_download_error` is named as a further
        addition -- and a generated client that hard-rejects an unrecognized
        bucket would fail hardest exactly when something has already gone wrong.
        Treat an unknown value as `internal_error`. Bucketing loses no
        granularity - the specific provider-level reason survives in
        `RouterValidationErrorDetail.type` and its `ctx`.
      example: invalid_input
      type: string
      x-comfy-error-types:
        - meaning: >-
            The request was rejected before it reached the model - a malformed
            body, a malformed or expired pagination cursor, an input the model's
            own schema does not accept, or an `Idempotency-Key` that cannot
            serve this request (already used for a different request - the
            method, the path and query, or the body differ - or already consumed
            by a call whose response cannot be replayed). Sent with `409` in the
            key cases and with `400`/`422` in the others; the status says which,
            and the key cases are the ones answered by using a new key rather
            than by editing the request.
          tier: request
          value: invalid_input
        - meaning: >-
            The provider refused the request on content-policy grounds. The
            refusal is deterministic: re-sending the same input will be refused
            again.
          tier: request
          value: content_policy_violation
        - meaning: >-
            The partner provider reported a failure of its own, or returned a
            response Router could not interpret as a result.
          tier: request
          value: provider_error
        - meaning: >-
            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.
          tier: request
          value: provider_timeout
        - meaning: The calling workspace does not have enough credits to run the model.
          tier: request
          value: insufficient_credits
        - meaning: >-
            The `{provider}/{model}` ID names no model Router can run: an
            unknown provider, an unenrolled id, one the catalog has retired past
            its sunset date, or a catalogued ID the provider does not currently
            serve for Comfy. A retired id's `detail` reads `<id> was retired on
            <YYYY-MM-DD>; use <successor>` when a successor is recorded and not
            withheld, or just `<id> was retired on <YYYY-MM-DD>` when it is not,
            instead of a suggestion list; the successor named for a retired id
            is always a live catalog ID you can re-point the call at, and a
            successor is dropped from `detail` either because the table records
            none, or because the recorded one is currently withheld behind an
            embargo the caller cannot see. A model whose sunset is still in the
            future is not withdrawn: it stays listed by `GET /v2/models` and
            stays runnable until the date, so a listing taken today remains the
            answer to what runs today. Otherwise `detail` carries up to three
            suggestions drawn from the models the caller is entitled to see,
            except for a catalogued ID the provider does not currently serve for
            Comfy, whose response carries no suggestions.
          tier: request
          value: model_not_found
        - meaning: The request carried no usable credential.
          tier: transport
          value: unauthorized
        - meaning: >-
            The credential is valid but is not entitled to this model or this
            operation.
          tier: transport
          value: forbidden
        - meaning: >-
            The workspace already has as many calls in flight as it is allowed;
            retry once one of them finishes. It carries one further condition on
            the run route, on a `409` rather than the `429` above: another call
            is already in flight for the `Idempotency-Key` this request
            presented. Re-send the same key after `Retry-After` seconds to
            collect that call's result.
          tier: transport
          value: concurrency_limit_exceeded
        - meaning: >-
            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.
          tier: transport
          value: client_disconnected
        - meaning: >-
            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.
          tier: transport
          value: internal_error
        - meaning: >-
            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. Retry it with the same `Idempotency-Key`:
            when the provider had already accepted the generation, the retry
            collects that generation rather than dispatching another, and a
            `Retry-After` on the `504` says when to ask.
          tier: transport
          value: deadline_exceeded
        - meaning: >-
            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. The one exception to "about
            the caller" is the queued submit, which also answers `not_enabled`
            for a model whose partner answers a generation directly as bytes:
            that model cannot yet be queued, so it is the model and not the
            caller that is refused, nothing is queued or charged, and the
            synchronous route `POST /v2/models/{provider}/{model}` runs it
            instead.
          tier: transport
          value: not_enabled
        - meaning: >-
            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".
          tier: transport
          value: service_unavailable
        - meaning: >-
            The caller has spent an allowance measured over a window and must
            wait for that window to roll. It shares `429` with
            `concurrency_limit_exceeded` and is not the same thing: that one
            clears the moment one of the caller's own in-flight calls finishes,
            so retrying in seconds is right, whereas nothing the caller does
            drains this one early. `detail` names the window.
          tier: transport
          value: rate_limited
        - meaning: >-
            A queued request was withdrawn — through the cancel route, or by an
            operator — before it produced a result; it is terminal, and it is
            not by itself a statement about the charge. Cancelling stops Comfy
            waiting and it does not always stop the partner working, so a
            request cancelled while it was still `IN_QUEUE` was never dispatched
            and cannot be charged, whereas one cancelled after it was admitted
            may still be charged — a partner generation that completes is
            charged whether or not anyone collected it, which is why the cancel
            route calls the ask a request rather than a guarantee. It is not
            `client_disconnected`: that one says nobody is listening any more
            while a generation may still be running and billable, whereas this
            says the request itself was withdrawn. A caller polling the status
            read sees `200` with this in the body, because the request ended and
            so the read succeeded. Collecting the result of such a request
            answers `409` in Router's error envelope: that read also worked, and
            what it found is a request whose terminal state - the caller's own
            decision - leaves nothing to return. It is deliberately not `410`,
            which on that route means the result aged out of its retention
            window, and not a `5xx`, which would report the caller's own
            cancellation as a Router fault and read to a polling client as worth
            retrying.
          tier: transport
          value: cancelled
        - meaning: >-
            A queued request waited past its queue timeout without ever being
            admitted. Terminal, unbilled, and it never took a concurrency slot —
            the job never reached a provider. Deliberately not
            `deadline_exceeded`, which is the synchronous route's connection
            bound: a `deadline_exceeded` generation may be running and billable,
            whereas this one provably never started. It is returned under `504`,
            which it shares with `provider_timeout` and `deadline_exceeded`
            because a status can only say that a clock ran out; which clock -
            the partner's, Comfy's connection bound, or the queue's admission
            bound - is what `error_type` carries. The request itself is
            terminal, so submit a new one rather than re-reading this one.
          tier: transport
          value: queue_timeout
        - meaning: >-
            The `request_id` names no request of the caller's under this model.
            It is the second of the two conditions the queued reads' `404`
            covers; the first is the `{provider}/{model}` ID resolving to no
            partner model, which is `model_not_found` and carries fuzzy model
            suggestions. It also covers the right-id / wrong-model URL the path
            shape refuses, and it is deliberately indistinguishable from a
            request in another workspace, so a probe with a guessed id learns
            nothing. A request that has merely aged out of its retention window
            is `410`, not this.
          tier: transport
          value: request_not_found
        - meaning: >-
            The caller already has too many queued requests waiting to run, so
            this submit was refused. It shares `429` with
            `concurrency_limit_exceeded` and is not the same thing: that one is
            the synchronous route's answer for too many calls in flight at once,
            whereas the queue accepts a submit at that limit and parks it, and
            this bucket is the separate bound on how many a caller may leave
            waiting so that parking cannot mean enqueuing without end. It clears
            as the caller's own queued requests finish, so retry once some of
            them complete.
          tier: transport
          value: queue_backlog_full
    RouterChargesOnPolicyRejection:
      description: >-
        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.

        It pairs with `error_type: content_policy_violation`
        (`RouterErrorType`): that value makes a policy refusal distinguishable,
        this one says what it costs.

        THREE values, and the third is not a formality:

        - `yes` - a policy refusal of this model IS charged. The caller pays for
        a
          generation they did not receive.
        - `no` - a policy refusal of this model is not charged. - `unknown` -
        nobody has established this model's behaviour yet.

        `unknown` exists because the alternative is to default to `no`, and `no`
        is a CLAIM: it tells a caller we do not charge them. Making that the
        default would assert it about every model nobody has checked, and it is
        the expensive direction to be wrong in - it is the answer a user quotes
        back at support. So an unestablished model says `unknown` and means it.

        It is a plain string rather than an `enum`, for the same reason
        `RouterErrorType` and every other extensible Router vocabulary is: a
        generated client that hard-rejects an unrecognized value fails hardest
        on exactly the models it has not been regenerated for. Treat an
        unrecognized value as `unknown`.

        The value is per MODEL and is not derived from the provider: a provider
        can differ across its own operations, and at least one already does, so
        a caller must read it for the model it is about to call rather than for
        the model's provider.
      example: unknown
      type: string
  headers:
    RouterCatalogCacheControlHeader:
      description: >-
        Freshness directives for a catalog page or entry. `private` because the
        answer is decided per credential (the response also carries `Vary:
        Authorization, X-API-Key`), so a shared cache must not hold it, and
        `must-revalidate` so a stale copy is revalidated against the `ETag`
        rather than served on. Error responses on these routes are `private,
        no-store` instead.
      schema:
        example: private, max-age=300, must-revalidate
        type: string
    RouterCatalogETagHeader:
      description: >-
        Strong entity tag over the served body's bytes, for `GET /v2/models` and
        `GET /v2/models/{provider}/{model}`. A caller that re-reads the catalog
        should store this value and send it back as `If-None-Match` to get a
        `304` instead of the body.

        It is STRONG (no `W/` prefix) and a digest of the exact bytes served,
        which are decided AFTER the release embargo is applied for this caller -
        so a model being held or released changes the tag of every page or entry
        it appears on. `If-None-Match` is compared by the weak-comparison rule
        RFC 9110 mandates, so a cache that stored the tag weakly still matches.
      required: true
      schema:
        example: '"6b8c1f2e0a9d4c3b5e7f8a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f"'
        type: string
    RouterRequestIdHeader:
      description: >-
        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.

        It is minted by the server and is never read from a request header of
        the same name: a caller-controlled id would let two unrelated calls
        collide in the audit trail, which would make the join actively
        misleading rather than merely absent. Sending this header on a request
        has no effect.
      required: true
      schema:
        example: 6f1a1a6e-6a53-4a5f-9d3a-2b3b0a1f9c21
        format: uuid
        type: string
    RouterErrorTypeHeader:
      description: >-
        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 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.
      required: true
      schema:
        $ref: '#/components/schemas/RouterErrorType'
  responses:
    RouterRequestError:
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/RouterErrorResponse'
      description: >-
        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`.
      headers:
        X-Comfy-Error-Type:
          $ref: '#/components/headers/RouterErrorTypeHeader'
        X-Comfy-Request-Id:
          $ref: '#/components/headers/RouterRequestIdHeader'
  securitySchemes:
    BearerAuth:
      bearerFormat: JWT
      description: >-
        Bearer token authentication. Normally a Firebase or Cloud JWT. A
        'comfyui-' prefixed API key is also accepted in this header: the prefix
        classifies the value as an API key and it is validated exactly as if it
        had been sent in X-API-Key.

        Accepted on the operations served by the comfyFirebase auth middleware
        (BE-9720, parity with ingest).
      scheme: bearer
      type: http
    ApiKeyAuth:
      description: >-
        API key authentication. Send the key in the X-API-Key header; keys are
        prefixed with 'comfyui-' and are generated from user account settings.
        The same 'comfyui-' key is also accepted in Authorization: Bearer (see
        BearerAuth), and when both headers carry a key, X-API-Key wins.

        The comfyFirebase auth middleware checks this header before falling back
        to the Authorization bearer token, and it backs both the /customers and
        the /proxy authset entries.
      in: header
      name: X-API-Key
      type: apiKey

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.