Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 93 additions & 3 deletions spec/router-openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,35 @@ paths:
parameters:
- $ref: '#/components/parameters/RouterCatalogCursor'
- $ref: '#/components/parameters/RouterCatalogLimit'
- in: header
name: If-None-Match
required: false
description: The `ETag` a caller holds from an earlier `200`. When it matches the current page (RFC 9110 weak comparison; `*` matches any current page) the answer is a bodyless `304` carrying the same `ETag`, otherwise the full page.
schema:
type: string
responses:
'200':
description: OK - one page of the model catalog.
headers:
X-Comfy-Request-Id:
$ref: '#/components/headers/RouterRequestIdHeader'
ETag:
$ref: '#/components/headers/RouterCatalogETagHeader'
Cache-Control:
$ref: '#/components/headers/RouterCatalogCacheControlHeader'
content:
application/json:
schema:
$ref: '#/components/schemas/RouterModelListResponse'
'304':
description: Not Modified - the page is unchanged since the `ETag` the caller sent in `If-None-Match`. No body is returned.
headers:
X-Comfy-Request-Id:
$ref: '#/components/headers/RouterRequestIdHeader'
ETag:
$ref: '#/components/headers/RouterCatalogETagHeader'
Cache-Control:
$ref: '#/components/headers/RouterCatalogCacheControlHeader'
'400':
$ref: '#/components/responses/RouterRequestError'
'401':
Expand All @@ -59,16 +78,35 @@ paths:
parameters:
- $ref: '#/components/parameters/RouterProvider'
- $ref: '#/components/parameters/RouterModel'
- in: header
name: If-None-Match
required: false
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.
schema:
type: string
responses:
'200':
description: OK - the model's catalog entry.
headers:
X-Comfy-Request-Id:
$ref: '#/components/headers/RouterRequestIdHeader'
ETag:
$ref: '#/components/headers/RouterCatalogETagHeader'
Cache-Control:
$ref: '#/components/headers/RouterCatalogCacheControlHeader'
content:
application/json:
schema:
$ref: '#/components/schemas/RouterModelDetail'
'304':
description: Not Modified - the entry is unchanged since the `ETag` the caller sent in `If-None-Match`. No body is returned.
headers:
X-Comfy-Request-Id:
$ref: '#/components/headers/RouterRequestIdHeader'
ETag:
$ref: '#/components/headers/RouterCatalogETagHeader'
Cache-Control:
$ref: '#/components/headers/RouterCatalogCacheControlHeader'
'401':
$ref: '#/components/responses/RouterRequestError'
'403':
Expand All @@ -79,7 +117,7 @@ paths:
$ref: '#/components/responses/RouterRequestError'
post:
summary: Run a partner model synchronously by canonical model ID.
description: 'Comfy Router''s canonical, model-ID-addressed entry point. The request body is the partner model''s own native JSON input and the success response is that model''s own native JSON output: Router forwards both unchanged instead of imposing a Comfy-shaped envelope, so a caller can move between the partner''s API and Router by changing the host. This is the synchronous path: the response carries the finished result.'
description: 'Comfy Router''s canonical, model-ID-addressed entry point. The request body is the partner model''s own native JSON input and the success response is that model''s own native output — JSON for most models, but the partner''s raw bytes (for example `audio/mpeg` for a text-to-speech model) for a model whose partner answers with a binary body: Router forwards both without imposing a Comfy-shaped envelope, so for most models a caller can move between the partner''s API and Router by changing the host. The exception is a model whose partner operation takes an identifier in its request path: Router has the caller name it as a top-level body field and moves it into the partner path, so that request needs that one adaptation. This is the synchronous path: the response carries the finished result.'
operationId: runRouterModel
tags:
- Comfy Router
Expand Down Expand Up @@ -111,6 +149,8 @@ paths:
$ref: '#/components/headers/RouterNoSniffHeader'
X-Comfy-Router-Fallback-Provider:
$ref: '#/components/headers/RouterFallbackProviderHeader'
X-Comfy-Router-Provider:
$ref: '#/components/headers/RouterProviderHeader'
X-Comfy-Router-Dropped-Params:
$ref: '#/components/headers/RouterDroppedParamsHeader'
X-Comfy-Credits-Used:
Expand Down Expand Up @@ -328,6 +368,8 @@ paths:
$ref: '#/components/headers/RouterRequestIdHeader'
X-Content-Type-Options:
$ref: '#/components/headers/RouterNoSniffHeader'
X-Comfy-Credits-Used:
$ref: '#/components/headers/RouterCreditsUsedHeader'
content:
application/json:
schema:
Expand Down Expand Up @@ -521,7 +563,7 @@ components:
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.
refusal_subject:
type: string
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; a provider whose refusal does not say which side it was about leaves it absent. It mirrors the `X-Comfy-Refusal-Subject` header.'
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.'
required:
- detail
- error_type
Expand Down Expand Up @@ -842,7 +884,7 @@ components:
- detail
responses:
RouterConcurrencyLimited:
description: 'The caller is holding as much in-flight capacity as they are allowed and the request was refused before it reached the model. The bucket is `concurrency_limit_exceeded` in either case and `detail` says which bound was hit: the number of concurrent calls, or the committed spend of the calls still in flight, whose refusal also carries the `X-Committed-Spend-Limit`, `X-Committed-Spend-Current` and `X-Committed-Spend-Remaining` headers (USD cents). Retry once one of the caller''s own in-flight calls finishes. The body is `RouterErrorResponse` and the bucket is repeated on `X-Comfy-Error-Type`.'
description: 'The caller is holding as much in-flight capacity as they are allowed and the request was refused before it reached the model. The bucket is `concurrency_limit_exceeded` in every case and `detail` says which bound was hit: the number of concurrent calls; the number of concurrent calls to models classed expensive, a quarter of the concurrent-call limit shared with partner-node calls, whose refusal also carries the `X-Model-Concurrency-Limit`, `X-Model-Concurrency-Current` and `X-Model-Concurrency-Remaining` headers; or the committed spend of the calls still in flight, whose refusal also carries the `X-Committed-Spend-Limit`, `X-Committed-Spend-Current` and `X-Committed-Spend-Remaining` headers (USD cents). Retry once one of the caller''s own in-flight calls finishes - for the expensive-model bound, one of their calls to an expensive model, which may be a partner-node call. The body is `RouterErrorResponse` and the bucket is repeated on `X-Comfy-Error-Type`.'
headers:
X-Comfy-Error-Type:
$ref: '#/components/headers/RouterErrorTypeHeader'
Expand All @@ -854,6 +896,12 @@ components:
$ref: '#/components/headers/CommittedSpendCurrentHeader'
X-Committed-Spend-Remaining:
$ref: '#/components/headers/CommittedSpendRemainingHeader'
X-Model-Concurrency-Limit:
$ref: '#/components/headers/ModelConcurrencyLimitHeader'
X-Model-Concurrency-Current:
$ref: '#/components/headers/ModelConcurrencyCurrentHeader'
X-Model-Concurrency-Remaining:
$ref: '#/components/headers/ModelConcurrencyRemainingHeader'
content:
application/json:
schema:
Expand Down Expand Up @@ -1080,13 +1128,49 @@ components:
format: int64
minimum: 0
example: 400
ModelConcurrencyCurrentHeader:
description: The caller's concurrent calls to models classed expensive, as counted by the gate that refused this call. Present alongside `X-Model-Concurrency-Limit`.
required: false
schema:
type: integer
format: int64
minimum: 0
example: 1
ModelConcurrencyLimitHeader:
description: The most concurrent calls to models classed expensive the caller may hold at once, a quarter of the concurrent-call limit, shared with partner-node calls. Present on a `429` raised by that bound.
required: false
schema:
type: integer
format: int64
minimum: 0
example: 1
ModelConcurrencyRemainingHeader:
description: The expensive-model calls the caller may still start; `0` on the `429`. Present alongside `X-Model-Concurrency-Limit`.
required: false
schema:
type: integer
format: int64
minimum: 0
example: 0
RouterCapacityRetryAfterHeader:
description: Seconds to wait before re-sending the same request, unchanged. It is present only when Router refused the request body for capacity - the in-flight request-body budget was full, or no slot was free to scan an oversized body - and a capacity refusal submitted nothing and charged nothing, so re-sending the identical call once the interval has passed is the whole remedy. There is no `Idempotency-Key` to collect under and no queued request to poll for; this is the one `Retry-After` on Router that means simply "send it again later". It is absent on the dependency-fault `503`s that share this status - a credential rail, an idempotency store or a policy decision point that could not answer - because none of them knows when it will, and a wrong number is worse than none against a dependency that is already failing.
required: false
schema:
type: integer
minimum: 1
example: 4
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.'
required: false
schema:
type: string
example: private, max-age=300, must-revalidate
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.
required: true
schema:
type: string
example: '"6b8c1f2e0a9d4c3b5e7f8a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f"'
RouterCreditsUsedHeader:
description: 'What this run cost, in Comfy credits, priced from the same rate card the charge itself is billed against - so a caller needs no price table of its own, and on a run that reached the provider through more than one billed call it is their sum rather than the last one. It reports a price, not a settled ledger entry. On a model Comfy bills while your request is still open, the value is published only once the usage event was accepted by billing; on a model that submits and then polls, it is the price the asynchronous worker will bill, recorded before that charge settles - so a run whose billing later fails can still have carried this header. Treat it as what you will be charged, not as proof you were, and reconcile against the usage and billing API rather than against this header alone. Absent whenever no cost was reported: a request billed against your own provider key, a partner whose response does not carry every dimension its price is computed from, a usage event that matched no billable metric, or a charge that did not reach billing - so a missing header means "not reported" and must not be read as "free", and a report that sums absent headers as zero will not reconcile. A run that was rated and genuinely cost nothing reads `0`, which is a reported cost rather than a missing one, so branch on whether the header is present rather than on whether its value is non-zero. Coverage is partial today and widening, so do not assume the header is present for every model. On a response that also carries `Idempotent-Replayed: true` this restates what the original run cost, and that run is charged once however many times you retry the key - so do not add the header up across retries of one `Idempotency-Key`. Absent on an error response: it is written only on the path that returns a result, which a refused call never reaches.'
required: false
Expand Down Expand Up @@ -1123,6 +1207,12 @@ components:
enum:
- nosniff
example: nosniff
RouterProviderHeader:
description: The provider of the Router leg that served this call, present on every successful run response - the model's own provider on a native run, the alternate provider `model_provider` named, or the provider a `fallback_provider` retry landed on (which `X-Comfy-Router-Fallback-Provider` also names, only in that case) - which is the leg Router dispatched to and not necessarily the vendor that produced the output, since a proxy-level cascade inside a native leg (the `vertexai` Gemini image chain, for one) can hand the call to a different vendor that no header names. Absent on an error response. It is stored with the `Idempotency-Key` record and replayed unchanged on a same-key retry; a retry of a call recorded before this header existed carries none.
required: false
schema:
type: string
example: openai
RouterQueuePollAfterHeader:
description: Seconds to wait before polling this queued request again. It is Router's own estimate of when asking again is worth the round trip, and it moves with how far the request has actually got - a request at the back of the queue is told to wait longer than one already running.
required: false
Expand Down
15 changes: 10 additions & 5 deletions tests/test_router_spec_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,12 +310,17 @@ def test_the_catalog_query_parameters_are_the_ones_the_spec_declares() -> None:
# `model_catalog_path` sends `cursor` and `limit` by name; a sync renaming
# either would otherwise be silently ignored by the server.
doc = yaml.safe_load(ROUTER_SPEC.read_text(encoding="utf-8"))
params = doc["paths"][_MODEL_CATALOG_PATH]["get"]["parameters"]
shared = doc["components"]["parameters"]
names = {
shared[p["$ref"].rsplit("/", 1)[-1]]["name"] if "$ref" in p else p["name"] for p in params
}
assert names == {"cursor", "limit"}
params = [
shared[p["$ref"].rsplit("/", 1)[-1]] if "$ref" in p else p
for p in doc["paths"][_MODEL_CATALOG_PATH]["get"]["parameters"]
]
assert {p["name"] for p in params if p["in"] == "query"} == {"cursor", "limit"}
# The catalog also accepts a conditional `If-None-Match` header, which
# `models.list()` never sends; that is only correct while every
# non-query parameter stays optional.
required = [p["name"] for p in params if p["in"] != "query" and p.get("required")]
assert required == [], f"the catalog now requires {required}, which the SDK never sends"
assert model_catalog_path("c", 5) == f"{_MODEL_CATALOG_PATH}?cursor=c&limit=5"


Expand Down
Loading