From 3170f79ffbc53013b5f93d7292fdea08d890d8bf Mon Sep 17 00:00:00 2001 From: mattmillerai <7741082+mattmillerai@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:02:51 +0000 Subject: [PATCH 1/2] chore: sync vendored Comfy Router spec from cloud@2d1f47c --- spec/router-openapi.yaml | 98 ++++++++++++++++++++++++++++++++++------ 1 file changed, 83 insertions(+), 15 deletions(-) diff --git a/spec/router-openapi.yaml b/spec/router-openapi.yaml index ee5dd54..34aceb9 100644 --- a/spec/router-openapi.yaml +++ b/spec/router-openapi.yaml @@ -93,9 +93,10 @@ paths: - $ref: '#/components/parameters/ModelProvider' - $ref: '#/components/parameters/StrictMode' - $ref: '#/components/parameters/FallbackProvider' + - $ref: '#/components/parameters/RejectUnknownFields' requestBody: required: true - description: The partner model's native JSON input. Without `model_provider`, or with `strict_mode=true`, forwarded to the provider unchanged - under `strict_mode=true` the body must already be the alternate provider's own real schema, not this model's native one (see `strict_mode`). With `model_provider` selecting an alternate provider and `strict_mode=false` (the default), the body is translated into that provider's real schema before it is sent - any native field that cannot be expressed exactly is dropped and disclosed via the response's `X-Comfy-Router-Dropped-Params` header, never silently. + description: 'The partner model''s native JSON input. Without `model_provider` the request runs native dispatch on the model''s default provider and this body is the model''s own native schema, forwarded unchanged (`strict_mode` is meaningless there and changes nothing). With `model_provider` selecting an alternate provider and `strict_mode=false` (the default), the body is translated into that provider''s real schema before it is sent - any native field that cannot be expressed exactly is dropped and disclosed via the response''s `X-Comfy-Router-Dropped-Params` header, never silently. With `model_provider` selecting an alternate provider and `strict_mode=true` no translation runs: the body must already be that alternate provider''s own real schema, not this model''s native one (see `strict_mode`), and is forwarded unchanged. The body''s own fields also select which operation Router runs on a model that supports more than one: for an editable image model, including an input image switches it from text-to-image to the image-to-image (edit) operation; for a Seedance video model, a first-frame image selects image-to-video and a reference image or clip selects reference-to-video. Each conditioned operation is metered on its own rate, not the base text-to-image or text-to-video rate.' content: application/json: schema: @@ -151,7 +152,7 @@ paths: '502': $ref: '#/components/responses/RouterProviderError' '503': - $ref: '#/components/responses/RouterRequestError' + $ref: '#/components/responses/RouterRequestUnavailable' '504': $ref: '#/components/responses/RouterDeadlineExceeded' /v2/models/{provider}/{model}/openapi.json: @@ -219,10 +220,13 @@ paths: parameters: - $ref: '#/components/parameters/RouterProvider' - $ref: '#/components/parameters/RouterModel' + - $ref: '#/components/parameters/ModelProvider' + - $ref: '#/components/parameters/StrictMode' - $ref: '#/components/parameters/RouterIdempotencyKey' + - $ref: '#/components/parameters/RejectUnknownFields' requestBody: required: true - description: The partner model's native JSON input, identical to the body the synchronous route accepts for this model. Validated against the model's own input schema before the run is admitted, so a body the model would reject is a `422` here rather than a queued request that fails minutes later. + description: 'The partner model''s native JSON input, identical to the body the synchronous route accepts for this model, and it selects the operation and is metered the same way: the body''s own fields choose which operation Router runs on a model that supports more than one (an input image switches an editable image model to image-to-image; a Seedance first-frame image selects image-to-video and a reference image or clip selects reference-to-video), and each conditioned operation is metered on its own rate, not the base text-to-image or text-to-video rate. The same provider-selection contract applies at dispatch: without `model_provider`, or with `model_provider` and `strict_mode=false` (the default), the body is the model''s native document and is validated against the model''s own input schema before the run is admitted, so a body the model would reject is a `422` here rather than a queued request that fails minutes later - a non-strict alternate-provider body is additionally translated into that provider''s real schema at dispatch. With `strict_mode=true` the body must already be the alternate provider''s own schema and is forwarded unchanged: native-schema validation is skipped, exactly as on the synchronous route (see `model_provider` and `strict_mode`).' content: application/json: schema: @@ -248,7 +252,7 @@ paths: '402': $ref: '#/components/responses/RouterRequestError' '403': - $ref: '#/components/responses/RouterRequestError' + $ref: '#/components/responses/RouterQueueSubmitForbidden' '404': $ref: '#/components/responses/RouterRequestError' '409': @@ -256,7 +260,7 @@ paths: '422': $ref: '#/components/responses/RouterModelValidationError' '503': - $ref: '#/components/responses/RouterRequestError' + $ref: '#/components/responses/RouterRequestUnavailable' /v2/models/{provider}/{model}/requests/{request_id}: get: summary: Collect the result of one submitted request. @@ -273,7 +277,7 @@ paths: - $ref: '#/components/parameters/RouterQueueRequestId' responses: '200': - description: 'OK - the partner model''s native output for a request that produced one - a request that completed successfully, or a terminal one that carries both a recorded charge and a stored result - returned unchanged under the partner''s own media type, exactly as the synchronous route''s `200` returns it. For most models that is JSON (`RouterModelOutput`); for a model whose partner answers a generation directly as bytes it is those bytes under the partner''s own `Content-Type`. A client must branch on the response `Content-Type` and must not assume a JSON document; the per-model contract is published at `GET /v2/models/{provider}/{model}/openapi.json`. This response carries `X-Content-Type-Options: nosniff`, so a partner media type is taken at its word and never sniffed into something else.' + description: 'OK - the result stored for a request that produced one - a request that completed successfully, or a terminal one that carries both a recorded charge and a stored result - returned unchanged under the partner''s own media type, exactly as the synchronous route''s `200` returns it, and under the same provider-selection contract the submit accepted: without `model_provider`, or with `model_provider` and `strict_mode=false` (the default, translated back into this model''s native contract when possible, falling back to the alternate provider''s own raw response on a translation failure - logged, never silent), the shape is this model''s own native output; with `strict_mode=true` it is the alternate provider''s response returned unchanged. For most models that is JSON (`RouterModelOutput`); for a model whose partner answers a generation directly as bytes it is those bytes under the partner''s own `Content-Type`. Today no such model can be queued (the submit refuses it), so this branch is declared for the SDK contract ahead of the server serving it. A client must branch on the response `Content-Type` and must not assume a JSON document; the per-model contract is published at `GET /v2/models/{provider}/{model}/openapi.json`. This response carries `X-Content-Type-Options: nosniff`, so a partner media type is taken at its word and never sniffed into something else.' headers: X-Comfy-Request-Id: $ref: '#/components/headers/RouterRequestIdHeader' @@ -414,12 +418,18 @@ components: description: Human-readable description of the failure, safe to surface to an end user. Not machine-parsed - branch on `error_type` instead. error_type: $ref: '#/components/schemas/RouterErrorType' + upstream_detail: + type: string + 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.' required: - detail - error_type RouterErrorType: type: string - 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 eighteen 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` and `request_not_found`. 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.' + 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.' example: invalid_input x-comfy-error-types: - value: invalid_input @@ -460,7 +470,7 @@ components: 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.' - value: not_enabled tier: transport - 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.' + 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.' - value: service_unavailable tier: transport 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".' @@ -476,6 +486,9 @@ components: - value: request_not_found tier: transport 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. + - value: queue_backlog_full + tier: transport + 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.' RouterModelBilling: type: object description: Per-model billing facts a caller needs before invoking - not prices. Usage and cost figures never appear here. @@ -759,7 +772,7 @@ components: schema: $ref: '#/components/schemas/RouterErrorResponse' RouterIdempotencyConflict: - description: 'The `Idempotency-Key` on this request is already held, and this request cannot be answered from its record. Two conditions share the status and `X-Comfy-Error-Type` is what separates them, because they are acted on in opposite ways. `concurrency_limit_exceeded` means the original call for this key is still running: wait `Retry-After` seconds and re-send the same key, which collects that call''s result rather than starting a second one. `invalid_input` means the key cannot serve this request at all - it was already used for a different request (the method, the path and query, or the body differ from the original), or the original completed (and, if it succeeded, was charged) and Router holds no copy of its response it can still stand behind - for example it was too large to store, or it names an asset Comfy does not host and so cannot promise still resolves, which on a direct-return model is replayed for a few minutes after the original call and refused after that - or the copy it holds is content-encoded in a way this request did not accept - and the answer is always a new key, never a re-send of this one. There is no `Retry-After` on any of these, because waiting changes nothing. `detail` says which case it is; the different-request case says nothing about how the call that does own the key turned out. The body is `RouterErrorResponse` and the bucket is repeated on `X-Comfy-Error-Type`.' + description: 'This request cannot be served as sent, and on `POST /v2/models/{provider}/{model}` three unrelated conditions answer this status. The first, which every route declaring this status can raise: the `Idempotency-Key` on this request is already held, and this request cannot be answered from its record. Two buckets share the status there and `X-Comfy-Error-Type` is what separates them, because they are acted on in opposite ways. `concurrency_limit_exceeded` means the original call for this key is still running: wait `Retry-After` seconds and re-send the same key, which collects that call''s result rather than starting a second one. `invalid_input` means the key cannot serve this request at all - it was already used for a different request (the method, the path and query, or the body differ from the original), or the original completed (and, if it succeeded, was charged) and Router holds no copy of its response it can still stand behind - for example it was too large to store, or it names an asset Comfy does not host and so cannot promise still resolves, which on a direct-return model is replayed for a few minutes after the original call and refused after that - or the copy it holds is content-encoded in a way this request did not accept - and the answer for the key is always a new key, never a re-send of this one. There is no `Retry-After` on any of these, because waiting changes nothing. `detail` says which case it is; the different-request case says nothing about how the call that does own the key turned out. The second and third conditions are raised only by `POST /v2/models/{provider}/{model}`, are not about the `Idempotency-Key` at all, and are both reachable on a request that carries no key. Router checks the third before the second, so a request that would trip both is refused for the third: an explicit `model_provider` naming an alternate provider on a request whose body selects a multipart operation (an edit, for example gpt-image''s `image` field), when that provider''s translator for this model cannot itself serve the operation - the swap is refused rather than silently billing a plain generation for the edit the caller actually asked for. A leg whose translator does carry the media is not refused and proceeds normally, so this refusal is per-leg rather than blanket. This one is not raised by the automatic on-failure retry `fallback_provider` controls, which is simply skipped (inert, not refused) on any multipart body, whether or not a leg could have served it - see the `model_provider` parameter. Its `detail` begins "this request''s `image` selects the edit operation". The second: `model_provider` named an alternate provider on a request that had already resolved a bring-your-own-key credential for the provider in the path. The three are mutually exclusive - the multipart case is decided from the request body and the named leg''s own translator, before BYOK is even considered - and the BYOK case is exclusive with the key case because that credential was resolved for the path''s provider while every alternate leg dispatches on Comfy''s own key for its own provider, so the call is refused before anything is dispatched and nothing is charged. Both the second and third carry `invalid_input`, the same bucket as the terminal key case above, so `X-Comfy-Error-Type` does not separate any of the three and `detail` is what a client branches on: the BYOK case begins `this request resolved a BYOK credential`, and a new key does not help - drop `model_provider`, or send the request without the BYOK credential. See the `model_provider` parameter, which also records that `fallback_provider` is inert rather than refused on a BYOK request. The body is `RouterErrorResponse` and the bucket is repeated on `X-Comfy-Error-Type`.' headers: X-Comfy-Error-Type: $ref: '#/components/headers/RouterErrorTypeHeader' @@ -772,7 +785,7 @@ components: schema: $ref: '#/components/schemas/RouterErrorResponse' RouterModelValidationError: - description: The request's contents were rejected against the model's schema. The body is `RouterValidationErrorResponse`, the FastAPI `detail[]` shape, so each offending field keeps its own specific `type` and `ctx`. `X-Comfy-Error-Type` carries the coarse bucket for the whole response. + description: The request's contents were rejected against the model's schema. The body is `RouterValidationErrorResponse`, the FastAPI `detail[]` shape, so each offending field keeps its own specific `type` and `ctx`. `X-Comfy-Error-Type` carries the coarse bucket for the whole response. A JSON request body that is not an object at all (an array, a string, a number, `null`) is refused here too, as `dict_type` at `["body"]`. headers: X-Comfy-Error-Type: $ref: '#/components/headers/RouterErrorTypeHeader' @@ -797,6 +810,17 @@ components: application/json: schema: $ref: '#/components/schemas/RouterErrorResponse' + RouterQueueSubmitForbidden: + 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`. On this route `not_enabled` also answers a model whose partner answers a generation directly as bytes: such a model cannot yet be queued, so the submit refuses it and nothing is queued or charged. For that refusal it is the model and not the account that is refused: run such a model on the synchronous route, `POST /v2/models/{provider}/{model}`, instead. The same redirect is given, in `detail`, to a bring-your-own-key request whose credential resolved but that the queue cannot carry. A credential that carries no Comfy workspace is refused `not_enabled` here too, with a `detail` naming the credential, while the synchronous route accepts it. Any other `not_enabled` or `forbidden` here is refused by the synchronous route the same way, so read `detail` rather than the bucket before re-sending elsewhere.' + headers: + X-Comfy-Error-Type: + $ref: '#/components/headers/RouterErrorTypeHeader' + X-Comfy-Request-Id: + $ref: '#/components/headers/RouterRequestIdHeader' + content: + application/json: + schema: + $ref: '#/components/schemas/RouterErrorResponse' RouterRequestError: 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: @@ -808,6 +832,19 @@ components: application/json: schema: $ref: '#/components/schemas/RouterErrorResponse' + RouterRequestUnavailable: + 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`. A `503` that was refused for capacity - the in-flight request-body budget was full, or no slot was free to scan an oversized body - carries `Retry-After` naming when to re-send the identical request; a `503` raised because a dependency faulted does not, because none of those rails knows when it will recover. + headers: + X-Comfy-Error-Type: + $ref: '#/components/headers/RouterErrorTypeHeader' + X-Comfy-Request-Id: + $ref: '#/components/headers/RouterRequestIdHeader' + Retry-After: + $ref: '#/components/headers/RouterCapacityRetryAfterHeader' + content: + application/json: + schema: + $ref: '#/components/schemas/RouterErrorResponse' RouterRunRequestError: 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`. On this route the status is also how the partner''s own refusal of a call that really ran is returned - the `content_policy_violation` some models meter - and that answer is recorded against an `Idempotency-Key` and served to a same-key retry, so unlike the catalog reads'' shared error this response can arrive carrying `Idempotent-Replayed: true`.' headers: @@ -817,6 +854,10 @@ components: $ref: '#/components/headers/RouterRequestIdHeader' X-Comfy-Upstream-Status: $ref: '#/components/headers/RouterUpstreamStatusHeader' + X-Comfy-Upstream-Detail: + $ref: '#/components/headers/RouterUpstreamDetailHeader' + X-Comfy-Refusal-Subject: + $ref: '#/components/headers/RouterRefusalSubjectHeader' Idempotent-Replayed: $ref: '#/components/headers/RouterIdempotentReplayedHeader' content: @@ -828,16 +869,24 @@ components: name: fallback_provider in: query required: false - description: 'Controls whether Router retries this call against the model''s other registered provider when the first attempt fails for a reason attributable to Router''s own side or to the specific provider tried - never for a reason attributable to the request itself (an unretried failure is refused exactly as it always was). Omitted, or any value other than `false`, turns fallback on (the default) and Router uses the one alternate the model has today. `false` turns fallback off: a failure is refused, never retried. A successful fallback response carries the `X-Comfy-Router-Fallback-Provider` header, naming the provider that served it; a fallback attempt that itself also fails does not carry the header, and no case retries a generation that may already have been submitted to a provider.' + description: 'Controls whether Router retries this call against another of the model''s registered providers when the first attempt fails for a reason attributable to Router''s own side or to the specific provider tried - never for a reason attributable to the request itself (an unretried failure is refused exactly as it always was). Omitted, or any value other than `false`, turns fallback on (the default) and Router scans the model''s registered alternates in a fixed order and retries once against the first eligible one. `false` turns fallback off: a failure is refused, never retried. A successful fallback response carries the `X-Comfy-Router-Fallback-Provider` header, naming the provider that served it; a fallback attempt that itself also fails does not carry the header, and no case retries a generation that may already have been submitted to a provider. Fallback also turns itself off, regardless of this parameter, on a request that resolved a bring-your-own-key credential, on a request whose body selects a multipart operation, and under `strict_mode=true` - each binds the call to one specific provider and cannot be faithfully replayed against another: the alternate leg would dispatch on Comfy''s own key rather than on the caller''s credential, no alternate translator is guaranteed to carry the multipart media field, and a strict body is already shaped for one provider rather than for the native contract. Each of those is inert, not refused - no retry is attempted and the first attempt''s own failure reaches the caller unchanged. Only an explicit `model_provider` is refused, and see that parameter for the `409`s it answers with; note that fallback''s multipart disable is unconditional, while `model_provider`''s multipart `409` applies only to a leg whose translator cannot serve the operation.' schema: type: string ModelProvider: name: model_provider in: query required: false - description: 'Selects an alternate provider for this model, instead of its current default. Omitted, or `default`, is byte-for-byte today''s behavior. A value naming a real provider that does not serve this model is refused `404` with `error_type: model_not_found` - the same bucket an unknown model ID itself uses; an unrecognized value (not a real, registered provider at all) is refused `400` with `error_type: invalid_input`. Router''s error_type set is closed (see the Router error contract note above); it carries neither a `provider_not_available` nor a `validation_error` bucket.' + description: 'Selects an alternate provider to serve this model, instead of its current default. Omitting it runs native dispatch on the model''s own default provider; `default`, `comfy`, and `comfyui` are aliases for that same native behavior and name no override, because Comfy Router is never itself a serving backend. The alternate providers Router can retarget a model onto are `fal`, `wavespeed`, `runware`, and `higgsfield`; which of them a given model supports is reported by `GET /v2/models/{provider}/{model}`. When an alternate provider is selected, the native request body is translated into that provider''s real schema unless `strict_mode=true`; see `strict_mode` and `fallback_provider`. Its refusals are checked in a fixed order, and an earlier one answers whether or not a later one would. A value that is not a registered provider at all is refused `400` with `error_type: invalid_input`. Then a request whose body selects a multipart operation (an edit - for example gpt-image''s `image` field) is refused `409`, also with `error_type: invalid_input`, whose `detail` begins "this request''s `image` selects the edit operation" - but only when the named provider''s translator for this model cannot itself serve that operation; a leg whose translator does carry the media is not refused and proceeds normally, so this is a per-leg refusal rather than a blanket one. Then a request that has already resolved a bring-your-own-key credential for the provider in the path is refused `409`, also with `error_type: invalid_input`, whose `detail` begins `this request resolved a BYOK credential` - see the `409` on this route, where that `detail` is the only thing separating this case, and the multipart case above, from the `Idempotency-Key` one. That BYOK check runs whether or not the named provider has a leg for this model. Then the named provider''s own gate refuses `403` with `error_type: not_enabled` when that provider is not turned on for you, and `503` with `error_type: service_unavailable` when the gate cannot be evaluated - a flag-evaluation failure, or a missing or nil gate entry. Only past all of those is a real provider that does not serve this model refused `400` with `error_type: invalid_input`, the same answer as a value that is not a registered provider at all - both mean the `model_provider` value cannot serve this model, and that `400`''s `detail` is human-readable and not a contract, so read `GET /v2/models/{provider}/{model}` to learn which providers a model does support rather than parsing it; past that `400`, this workspace''s partner-provider policy for the named vendor is evaluated too and can refuse `403` or `503` of its own. Neither this parameter nor `fallback_provider` is available on a BYOK request, and the two are unavailable in different ways: the credential was resolved for the provider named in the path while every alternate leg dispatches on Comfy''s own key, so an explicit `model_provider` is refused with that `409`, and `fallback_provider` is inert rather than refused - no retry against an alternate provider is attempted and the first attempt''s own failure is what the caller receives. The same split applies to a multipart body: only an explicit `model_provider` reaches the `409` above, and only for a leg whose translator cannot serve the operation, while the automatic on-failure retry `fallback_provider` controls is simply skipped (inert, not refused) for any multipart body. Router defines no `provider_not_available` or `validation_error` `error_type`: these conditions fold onto `invalid_input`. The `error_type` set can still grow, so treat any value you do not recognize as `internal_error` rather than switching exhaustively.' schema: type: string + RejectUnknownFields: + name: reject_unknown_fields + in: query + required: false + description: Opt in to refusing a top-level request-body field this model's input schema does not declare, rather than accepting it; nested fields are not checked, and nothing is refused for a model whose schema allows undeclared fields or has no authored schema, or on a queued submit sent with `strict_mode=true`. + schema: + type: boolean + default: false RouterCatalogCursor: name: cursor in: query @@ -889,7 +938,7 @@ components: name: strict_mode in: query required: false - description: 'Only meaningful together with `model_provider`. `false` (the default): the request body must be this model''s own native contract, translated to the alternate provider''s real schema - any native field that cannot be expressed exactly is dropped and disclosed via the response''s `X-Comfy-Router-Dropped-Params` header, never silently. `true`: the body must already be the alternate provider''s own real schema, passed through unmodified in both directions - no translation, so the header is never sent.' + description: 'Only meaningful together with `model_provider`. `false` (the default): the request body must be this model''s own native contract, translated to the alternate provider''s real schema - any native field that cannot be expressed exactly is dropped and disclosed via the response''s `X-Comfy-Router-Dropped-Params` header, never silently. `true`: the body must already be the alternate provider''s own real schema, passed through unmodified in both directions - no translation, so `X-Comfy-Router-Dropped-Params` is never sent, and provider fallback is disabled for the call because a body shaped for one provider cannot be replayed against another (see `fallback_provider`).' schema: type: boolean default: false @@ -918,6 +967,13 @@ components: format: int64 minimum: 0 example: 400 + 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 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 @@ -925,7 +981,7 @@ components: type: string example: '12.5' RouterDroppedParamsHeader: - description: One JSON-encoded string holding an array of strings - decode it with a JSON parser rather than splitting it on commas, because it is a single string on the wire, not a comma-separated OpenAPI array, and each entry is a sentence carrying commas of its own - present whenever a translation produced this call's request body and could not express one or more native fields exactly on the provider that served it, naming each dropped field and why, whether the caller asked for that translation with `model_provider` (`strict_mode=false`, the default) or an automatic `fallback_provider` retry ran it. Absent when no translation ran, when translation ran but dropped nothing, and on an error response. On a fallback retry it names what that retry's own translation (into the provider that actually served the call) dropped, never the primary attempt's. + description: 'One JSON-encoded string holding an array of strings - decode it with a JSON parser rather than splitting it on commas, because it is a single string on the wire, not a comma-separated OpenAPI array, and each entry is a sentence carrying commas of its own - present whenever a translation produced this call''s request body and could not express one or more native fields exactly on the provider that served it, naming each dropped field and why, whether the caller asked for that translation with `model_provider` (`strict_mode=false`, the default) or an automatic `fallback_provider` retry ran it. Absent when no translation ran, when translation ran but dropped nothing, and on an error response. On a fallback retry it names what that retry''s own translation (into the provider that actually served the call) dropped, never the primary attempt''s. The disclosure is bounded three ways, so that it stays a fixed size rather than one that grows with the request body — some native arrays (`input.media` on the Wan video family) are deliberately uncapped, and entries quote caller-supplied values: at most 64 entries, each at most 300 bytes plus an elision mark, summing to at most 4096 bytes of the encoded header value. Whichever bound binds first wins. Shortening is never silent: an entry cut to the per-entry bound ends in `…`, and when entries are left out entirely a final summarising entry states how many. Treat that count as a lower bound on the fields affected rather than a tally of them: it counts disclosure entries, and a translator may collapse many dropped elements into one entry (the Wan video family''s media groups do exactly that). Entries are also sanitised before publication — a media URL is stripped of its query string, which is what carries its credential — so an entry names the field it dropped rather than reproducing the value verbatim. It is stored with the `Idempotency-Key` record and replayed unchanged on a same-key retry (alongside `Idempotent-Replayed: true`), so a retry carries the same disclosure the original call did rather than reading as a drop-free run. In the one case where the stored headers were too large to keep in full, the record is marked non-replayable and the retry is refused with `409` rather than served without this header - a refusal a caller can act on, where a replay that silently carried no disclosure is the outcome this header exists to prevent.' required: false schema: type: string @@ -936,7 +992,7 @@ components: schema: $ref: '#/components/schemas/RouterErrorType' RouterFallbackProviderHeader: - description: Present, naming the provider, only when `fallback_provider` actually retried this call against a second provider and that retry succeeded - the provider that ultimately served the call, never one that was attempted and also failed. Absent when the primary attempt itself succeeded, and absent on an error response. See `fallback_provider` for the retry policy this discloses. + description: 'Present, naming the provider, only when `fallback_provider` actually retried this call against a second provider and that retry succeeded - the provider that ultimately served the call, never one that was attempted and also failed. Absent when the primary attempt itself succeeded, and absent on an error response. It is stored with the `Idempotency-Key` record and replayed unchanged on a same-key retry (alongside `Idempotent-Replayed: true`), so a retry names the same provider the original call did rather than reading as an unsubstituted run. In the one case where the stored headers were too large to keep in full, the record is marked non-replayable and the retry is refused with `409` rather than served without this header, so a same-key retry never reads as an unsubstituted run either way. See `fallback_provider` for the retry policy this discloses.' required: false schema: type: string @@ -961,6 +1017,12 @@ components: type: integer minimum: 1 example: 3 + RouterRefusalSubjectHeader: + 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`. Present only when `error_type` is `content_policy_violation` and the provider named the refused subject with a machine-readable code; absent otherwise - so branch on its presence. Never provider text. Mirrors `RouterErrorResponse.refusal_subject`.' + required: false + schema: + type: string + example: output_audio 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. required: true @@ -987,6 +1049,12 @@ components: schema: type: string example: '"6b8c1f2e0a9d4c3b5e7f8a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f"' + RouterUpstreamDetailHeader: + 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` - usually a `4xx`, and a `2xx` for a provider that reports a rejected generation inside a success envelope; absent otherwise - so branch on its presence. Mirrors `RouterErrorResponse.upstream_detail`. + required: false + schema: + type: string + example: expected the height to be at least 300px, but received a 445x283px image instead RouterUpstreamStatusHeader: description: 'The model provider''s own HTTP status for this call. Present only when the failure came from the provider, and absent whenever Comfy Router refused the call itself - so branch on its presence: present means the request left Comfy, reached the provider, and the provider''s answer is what produced this response''s `error_type`.' required: false From 4339df9b9c0cc4f95af3d31511db9f90b49848b0 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Fri, 2 Oct 2026 20:17:10 -0700 Subject: [PATCH 2/2] fix: reconcile the SDK with the synced Router spec (queue_backlog_full, not_enabled, credits_used) --- CHANGELOG.md | 13 +++++++++- README.md | 16 +++++++++--- src/comfy_sdk/models.py | 8 ++++++ src/comfy_sdk/router_exceptions.py | 29 +++++++++++++++++++++- tests/test_exception_modules.py | 1 + tests/test_router_exceptions.py | 21 +++++++++++++++- tests/test_router_spec_contract.py | 40 +++++++++++++++++++----------- 7 files changed, 107 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d28a4a5..c368ba3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,13 @@ the fuller account of each version, including verification notes. with `", "`), `NaN`/`Infinity` — reports as `None` rather than passing through to break the `Decimal()` parse the field documents. The field defaults to `None`, so this stays additive for anything that constructs a `RouterRunResult` by hand. + The vendored Router spec now declares `X-Comfy-Credits-Used` on the run route's `200`, so + `tests/test_router_spec_contract.py` pins this lift against the contract like the other four. +- `QueueBacklogFull` in `comfy_sdk.router_exceptions`, for the Router bucket `queue_backlog_full`: + a queued `submit` refused `429` because the caller already has too many requests waiting. It is + not `ConcurrencyLimitExceeded` — the queue parks a submit at the in-flight limit, and this is the + separate bound on how many may be left waiting. It clears as the caller's own queued requests + finish. Before this, the bucket arrived as a bare `RouterError`. ### Fixed @@ -73,8 +80,12 @@ the fuller account of each version, including verification notes. affected: `raise`, `except` and every attribute a caller reads inside the handler (`.message`, `.code`, `.http_status`, `.details`, `.request_id`, `.retry_after`) are unchanged. - `RouterError` is exported from the package root, alongside `CancelRefused` and - `AlreadyCompleted`. The eighteen per-bucket classes still live in + `AlreadyCompleted`. The nineteen per-bucket classes still live in `comfy_sdk.router_exceptions`. +- `NotEnabled`'s documented meaning widened with the synced Router spec: on a queued `submit` it + can also refuse a *model* whose partner answers a generation directly as bytes (it cannot yet be + queued; nothing is queued or charged; `models.run` serves it). It is still terminal, but on a + submit it no longer proves the caller is not switched on — read `.detail`. - `ApiError.error_type` records the Router bucket a response named (`X-Comfy-Error-Type`, or the body's `error_type`), or `None` when it named none — which is also how the SDK tells which surface answered. diff --git a/README.md b/README.md index f3daeda..b6065cf 100644 --- a/README.md +++ b/README.md @@ -604,7 +604,16 @@ async with AsyncComfy(api_key="comfyui-...") as client: This surface is **gated server side**. A caller the queue is not switched on for is answered `403 not_enabled`, which arrives as `comfy_sdk.router_exceptions.NotEnabled` — nothing about the request is wrong, -and it is terminal: do not retry it. +and it is terminal: do not retry it. The same `403 not_enabled` also refuses a +*model* whose partner answers a generation directly as bytes: it cannot yet be +queued, nothing is queued or charged, and `client.models.run` serves it instead — +so read `.detail` before concluding the account is not switched on. + +A caller with too many queued requests already waiting is refused +`429 queue_backlog_full`, raised as `comfy_sdk.router_exceptions.QueueBacklogFull`. +It is not `ConcurrencyLimitExceeded` (the synchronous route's in-flight bound): +the queue parks a submit at that limit, and this is the separate bound on how many +may be left waiting. It clears as your own queued requests finish. ### Retrying a run without paying for it twice @@ -879,12 +888,13 @@ status on `.http_status`; keep an `except ComfyError` outside the clause above if you need to handle those in the same place. `RouterError` is exported from the package root because it is the handler most -callers write first. The eighteen per-bucket classes stay in +callers write first. The nineteen per-bucket classes stay in `comfy_sdk.router_exceptions` — `InvalidInput`, `ContentPolicyViolation`, `ProviderError`, `ProviderTimeout`, `InsufficientCredits`, `ModelNotFound`, `Unauthorized`, `Forbidden`, `ConcurrencyLimitExceeded`, `ClientDisconnected`, `InternalError`, `DeadlineExceeded`, `NotEnabled`, `ServiceUnavailable`, -`RateLimited`, `Cancelled`, `QueueTimeout`, `RequestNotFound` — one import path +`RateLimited`, `Cancelled`, `QueueTimeout`, `RequestNotFound`, +`QueueBacklogFull` — one import path for the whole set rather than half of it here and half of it there. A bucket added to Router after your installed version arrives as `RouterError` itself, with the raw value readable on `.error_type`. diff --git a/src/comfy_sdk/models.py b/src/comfy_sdk/models.py index 61eaaa8..25ee7d3 100644 --- a/src/comfy_sdk/models.py +++ b/src/comfy_sdk/models.py @@ -723,6 +723,14 @@ def submit( for is answered ``403`` ``not_enabled``, which arrives here as :class:`~comfy_sdk.router_exceptions.NotEnabled`. Nothing about the request is wrong in that case, and it is terminal — do not retry it. + The same ``not_enabled`` also refuses a *model* whose partner answers a + generation directly as bytes: such a model cannot yet be queued, nothing + is queued or charged, and :meth:`run` serves it instead — so read + ``detail`` before concluding the caller is not switched on. A caller + with too many requests already waiting is refused ``429`` + ``queue_backlog_full`` + (:class:`~comfy_sdk.router_exceptions.QueueBacklogFull`), which clears + as its own queued requests finish. """ low = cast(ComfyLow, self._low) key = ( diff --git a/src/comfy_sdk/router_exceptions.py b/src/comfy_sdk/router_exceptions.py index 7b2924d..d38237d 100644 --- a/src/comfy_sdk/router_exceptions.py +++ b/src/comfy_sdk/router_exceptions.py @@ -407,10 +407,18 @@ class NotEnabled(RouterError): 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 + (:meth:`~comfy_sdk.models.Models.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 (:meth:`~comfy_sdk.models.Models.run`) runs it instead. On a submit, + read ``detail`` before concluding the account is not switched on. """ error_type = "not_enabled" - _spec_meaning_digest: str = "c4a48688282c" + _spec_meaning_digest: str = "571a30cc6ba0" class ServiceUnavailable(RouterError): @@ -522,6 +530,23 @@ class RequestNotFound(RouterError): _spec_meaning_digest: str = "385112b3cdcf" +class QueueBacklogFull(RouterError): + """The caller already has too many queued requests waiting to run, so this + submit was refused. + + It shares ``429`` with :class:`ConcurrencyLimitExceeded` 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. + """ + + error_type = "queue_backlog_full" + _spec_meaning_digest: str = "50745ff63044" + + # -- cancel refusals --------------------------------------------------------- # # Deliberately OUTSIDE the closed set below, and carrying no @@ -613,6 +638,7 @@ class AlreadyCompleted(CancelRefused): Cancelled, QueueTimeout, RequestNotFound, + QueueBacklogFull, ) _BY_ERROR_TYPE: dict[str, type[RouterError]] = {cls.error_type: cls for cls in ROUTER_EXCEPTIONS} @@ -886,6 +912,7 @@ def _detail_from(entry: Mapping[str, Any]) -> ValidationErrorDetail: "NotEnabled", "ProviderError", "ProviderTimeout", + "QueueBacklogFull", "QueueTimeout", "RateLimited", "RequestNotFound", diff --git a/tests/test_exception_modules.py b/tests/test_exception_modules.py index 5e2aac3..2a9f5fc 100644 --- a/tests/test_exception_modules.py +++ b/tests/test_exception_modules.py @@ -69,6 +69,7 @@ (409, "cancelled"), (504, "queue_timeout"), (404, "request_not_found"), + (429, "queue_backlog_full"), (418, "something_invented_later"), ] diff --git a/tests/test_router_exceptions.py b/tests/test_router_exceptions.py index 08a69e5..db56f57 100644 --- a/tests/test_router_exceptions.py +++ b/tests/test_router_exceptions.py @@ -31,6 +31,7 @@ NotEnabled, ProviderError, ProviderTimeout, + QueueBacklogFull, QueueTimeout, RateLimited, RequestNotFound, @@ -48,7 +49,8 @@ # The statuses are part of the case on purpose: they document the pairing a # retry policy keys on (502 provider_error vs 504 provider_timeout), and the # four status collisions the widened set introduced -- 403 forbidden vs -# not_enabled, 429 concurrency_limit_exceeded vs rate_limited, 504 +# not_enabled, 429 concurrency_limit_exceeded vs rate_limited (and, since, +# queue_backlog_full), 504 # provider_timeout vs deadline_exceeded, 500 internal_error vs the 503 # service_unavailable it is deliberately NOT merged with. CASES: list[tuple[str, int, type[RouterError]]] = [ @@ -70,6 +72,7 @@ ("cancelled", 409, Cancelled), ("queue_timeout", 504, QueueTimeout), ("request_not_found", 404, RequestNotFound), + ("queue_backlog_full", 429, QueueBacklogFull), ] # Deliberately not in the set this SDK version knows: a later milestone adds it, @@ -405,6 +408,22 @@ def test_a_rate_limited_429_is_not_the_concurrency_429() -> None: assert type(error_from_response(429, {}, None)) is ConcurrencyLimitExceeded +def test_a_queue_backlog_full_429_is_not_the_concurrency_429() -> None: + # The queue parks a submit at the in-flight limit; this is the separate + # bound on how many a caller may leave waiting, and it clears only as the + # caller's own queued requests finish -- not when one sync call returns. + exc = error_from_response( + 429, + {ERROR_TYPE_HEADER: "queue_backlog_full"}, + {"detail": "Too many queued requests.", "error_type": "queue_backlog_full"}, + ) + assert type(exc) is QueueBacklogFull + assert not isinstance(exc, ConcurrencyLimitExceeded) + # A bare 429 from an intermediary still reads as plain throttling: the + # backlog bound is Router's own claim, which a proxy cannot be making. + assert type(error_from_response(429, {}, None)) is ConcurrencyLimitExceeded + + # -- never crash the client on a malformed response -------------------------- diff --git a/tests/test_router_spec_contract.py b/tests/test_router_spec_contract.py index 4e51f76..b39a587 100644 --- a/tests/test_router_spec_contract.py +++ b/tests/test_router_spec_contract.py @@ -294,11 +294,22 @@ def test_the_bound_path_has_exactly_the_two_segments_the_binding_fills() -> None "dropped_params": "X-Comfy-Router-Dropped-Params", "replayed": "Idempotent-Replayed", "request_id": "X-Comfy-Request-Id", + "credits_used": "X-Comfy-Credits-Used", } +#: A value the lift will actually keep, for the lifts that validate what they +#: read. ``credits_used`` reports a non-decimal as ``None`` -- the same as an +#: absent header -- so probing it with an arbitrary string would make a correct +#: lift look like one reading the wrong name. ``12.5`` is the spec's own +#: example for the header. Anything not listed is probed with ``"x"``. +_LIFT_PROBE_VALUES = {"X-Comfy-Credits-Used": "12.5"} + #: Lifted by the SDK but NOT declared on the contract's 200 -- see the tripwire -#: test at the bottom of this file. -_UNDECLARED_HEADER_LIFTS = {"credits_used": "X-Comfy-Credits-Used"} +#: test at the bottom of this file. Empty today: ``credits_used`` sat here until +#: a spec sync declared ``X-Comfy-Credits-Used``, and was moved up into +#: ``_CONTRACT_HEADER_LIFTS`` then. Kept, rather than deleted with its test, so +#: the next lift the SDK reads ahead of the contract has somewhere to go. +_UNDECLARED_HEADER_LIFTS: dict[str, str] = {} def _declared_run_response_headers() -> set[str]: @@ -333,7 +344,7 @@ def test_the_lift_actually_reads_the_declared_name(field: str, header: str) -> N to fail. """ absent = getattr(_run_result({}, {}), field) - present = getattr(_run_result({}, {header: "x"}), field) + present = getattr(_run_result({}, {header: _LIFT_PROBE_VALUES.get(header, "x")}), field) assert present != absent, ( f"_run_result ignored {header!r}: RouterRunResult.{field} read {absent!r} both with " f"the header and without it, so the lift is reading some other name." @@ -346,18 +357,17 @@ def test_an_undeclared_lift_stays_undeclared_until_someone_reconciles_it( ) -> None: """Tripwire, and deliberately asserting the *absence*. - ``credits_used`` is lifted from a header the vendored contract does not - declare anywhere -- the 200's only cost headers are the - ``X-Committed-Spend-*`` trio, which is a different quantity (USD cents of - in-flight commitment, not the price of this run). Nothing in the suite can - catch a wrong name here, because every test configures its stub to emit the - exact literal the lift reads. - - That gap is tracked, not accepted. This test fails the moment a spec sync - declares the header, which is the signal to move the entry up into - ``_CONTRACT_HEADER_LIFTS`` and get it pinned like the rest. It also fails - if the header is declared under a *different* name for the same quantity, - because the reconciliation is the same either way. + For a lift the SDK reads from a header the vendored contract does not yet + declare. Nothing in the suite can catch a wrong name for such a lift, + because every test configures its stub to emit the exact literal the lift + reads -- so the gap is tracked, not accepted: this test fails the moment a + spec sync declares the header, which is the signal to move the entry up + into ``_CONTRACT_HEADER_LIFTS`` and get it pinned like the rest. + + ``credits_used`` (``X-Comfy-Credits-Used``) went through exactly that: it + was lifted ahead of the contract, this test fired on the sync that declared + it, and it now lives in ``_CONTRACT_HEADER_LIFTS``. With nothing left + undeclared the parametrization is empty, which pytest reports as a skip. """ declared = _declared_run_response_headers() assert header not in declared, (