From 6277b1987f0eed21938770ea2b2225feb4fe5414 Mon Sep 17 00:00:00 2001 From: Bryan Wade Date: Wed, 9 Sep 2026 16:59:55 -0700 Subject: [PATCH] Keep documentation generation owned by the docs repository Cloud remains authoritative for API contracts while docs renders pages from public snapshots and retains handwritten content. The sync workflow adds generated changes to the same PR without executing generator code with write credentials. Constraint: Configure docs PR_GH_TOKEN before enabling schema-only Cloud sync Rejected: GITHUB_TOKEN for generated commits | Suppresses normal PR check events Confidence: high Scope-risk: moderate Tested: 33 renderer tests, 16 snippet tests, 198 page validations, prune preservation, actionlint Not-tested: Live sync publishing; repository bot secret is not configured --- .../scripts/router/gen_router_reference.py | 765 ++++++++++++++++++ .../router/gen_router_reference_test.py | 438 ++++++++++ .github/scripts/snippets/README.md | 22 +- .github/scripts/snippets/gen-code-pages.ts | 2 +- .github/workflows/code-pages-check.yml | 19 + .github/workflows/router-docs-generate.yml | 122 +++ development/comfy-router/reference.mdx | 3 +- router-openapi.yaml | 611 ++++++++++++++ 8 files changed, 1975 insertions(+), 7 deletions(-) create mode 100644 .github/scripts/router/gen_router_reference.py create mode 100644 .github/scripts/router/gen_router_reference_test.py create mode 100644 .github/workflows/router-docs-generate.yml create mode 100644 router-openapi.yaml diff --git a/.github/scripts/router/gen_router_reference.py b/.github/scripts/router/gen_router_reference.py new file mode 100644 index 000000000..1daf1c659 --- /dev/null +++ b/.github/scripts/router/gen_router_reference.py @@ -0,0 +1,765 @@ +#!/usr/bin/env python3 +"""Render Comfy Router's reference from the public router-openapi.yaml snapshot. + +Cloud projects and leak-checks the API contract before syncing it here. Docs +owns this renderer and its editorial descriptions. Regenerate with: + python3 .github/scripts/router/gen_router_reference.py router-openapi.yaml development/comfy-router/reference.mdx +""" + +from __future__ import annotations + +import argparse +import pathlib +import re +import sys +from typing import Any + +import yaml + +ROUTER_TAG = "Comfy Router" + +HTTP_METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace") + +BANNER = """{/* + GENERATED FILE -- DO NOT HAND-EDIT. + + Produced from the Comfy API contract by gen_router_reference.py. Edit the + contract and regenerate; an edit made here is overwritten by the next run and + is rejected by the drift gate in the meantime. +*/}""" + +FRONTMATTER = """--- +title: "Comfy Router API reference" +sidebarTitle: "API Reference" +description: "Every Comfy Router endpoint, parameter, response body and error bucket, generated from the Comfy API contract." +---""" + + +# Editorial guidance owned by docs, separate from the generated API contract. +RESULT_ASSETS = """## Result assets + +A model can return asset URLs, inline bytes, or both. The providers below copy selected assets onto Comfy storage and replace their URLs. This behavior depends on the model; there is no request header that selects it. + +| Models | What is copied onto Comfy storage | Maximum Comfy-hosted URL lifetime | +| --- | --- | --- | +| `bfl/*` | the finished asset, and the draft-cache asset when the result carries one | 24 hours | +| `byteplus/*` video models (`seedance`, `dreamina-seedance`) | the finished video, and the last-frame image when the result carries one | 24 hours | +| `minimax/*` | the finished video | 12 hours | +| `xai/*` | every generated image, and the finished video | 24 hours | + +These lifetimes start when the URL is signed, not when you open it. Cached or replayed URLs can have less time remaining; replay does not renew them. Download the asset promptly. Only the assets named in each row are copied: `byteplus/seedream-*` and `byteplus/seededit-*` images are not covered by the BytePlus video row. + +**Veo (`veo/*`) has a separate storage path.** In `response.videos[]`, read whichever member is present: `bytesBase64Encoded` contains the clip inline, while `gcsUri` contains a Comfy-signed HTTPS link when the environment is configured for direct provider writes to Comfy storage. That link is valid for 24 hours from the response. The latter case writes the asset directly rather than copying it, so Veo is not in the rehosting table. + +Other models return provider asset references or inline bytes. Provider URLs follow the provider's expiry, which can be much shorter than the lifetimes above and is not specified by the Router contract. + +Copying is best effort per asset. If one copy fails, that entry keeps its provider reference; the response can contain both Comfy and provider URLs, with no explicit per-asset copy-status field. The generation still succeeds and is charged. Do not infer every URL's lifetime from one successfully rehosted asset. + +The `xai` and `minimax` adapters serialize through known provider types, so undeclared provider fields may be omitted. Consult the model's output schema rather than assuming that every field from a provider SDK is preserved. + +Whether a result is Comfy-hosted also decides whether a completed call can still be replayed from its `Idempotency-Key` record later; the `Idempotency-Key` parameter above says what a retry is answered with when it cannot be.""" + + +def lead(text: Any) -> str: + """First paragraph of a description, whitespace-normalized. + + Every description on the Router surface is a folded (`>-`) YAML scalar, and + folding has ALREADY collapsed each paragraph's own line breaks into spaces + by the time the value reaches here. A surviving `\\n` is therefore a + paragraph break, and splitting on a BLANK line instead would find none and + return the whole description -- rationale, internal notes and all. + """ + if not isinstance(text, str): + return "" + para = text.strip().split("\n", 1)[0] + return " ".join(para.split()) + + +def deref(doc: dict, node: Any) -> Any: + """Resolve a local `$ref` one hop. Non-local refs are a spec bug, not a fallback.""" + if not isinstance(node, dict) or "$ref" not in node: + return node + ref = node["$ref"] + if not ref.startswith("#/"): + raise ValueError(f"non-local $ref is not supported in this spec: {ref}") + cur: Any = doc + for part in ref[2:].split("/"): + cur = cur[part] + return cur + + +def ref_name(node: Any) -> str | None: + """Component name a `$ref` points at, or None for an inline node.""" + if isinstance(node, dict) and isinstance(node.get("$ref"), str): + return node["$ref"].rsplit("/", 1)[-1] + return None + + +def anchor(name: str) -> str: + """Mintlify/GitHub heading anchor for a schema section.""" + return "#" + re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + + +def schema_link(name: str) -> str: + return f"[`{name}`]({anchor(name)})" + + +_CODE_SPAN = re.compile(r"(`[^`]*`)") + + +def mdx(text: str) -> str: + """Neutralize MDX-hazardous characters in spec-authored prose. + + Mintlify parses this reference as MDX, where a bare `{` opens a JSX + expression and a bare `<` opens a JSX element -- either one turns a sentence + the spec author wrote as plain prose into a docs-build failure. Text inside a + backtick code span is already literal and is left alone, because escaping + there renders the entity instead of the character. + """ + parts = _CODE_SPAN.split(text) + for i, part in enumerate(parts): + if i % 2 == 0: + parts[i] = ( + part.replace("{", "{") + .replace("}", "}") + .replace("<", "<") + .replace(">", ">") + ) + return "".join(parts) + + +def cell(text: Any) -> str: + """Render a spec-authored value as the contents of ONE GFM table cell. + + A `|` opens a new column wherever it appears -- GFM requires it escaped even + inside a code span, and an alternation such as `^(fal|openai)/` is routine in + a `pattern` on this surface -- so it is escaped unconditionally. The table + parser turns `\\|` back into a literal `|` before inline parsing, so the code + spans still read correctly. + """ + return mdx(str(text)).replace("|", "\\|") + + +def summary(text: Any) -> str: + """One complete sentence for a dense reference table cell.""" + pieces = re.split(r"(`[^`]*`|\[[^\]]+\]\([^)]+\))", lead(text)) + result = "" + for index, piece in enumerate(pieces): + if index % 2 == 0: + for match in re.finditer(r"[.!?](?=\s|$)", piece): + candidate = result + piece[:match.end()] + if not re.search(r"\b(?:e\.g|i\.e|etc)\.$", candidate): + return candidate.strip() + result += piece + return result.strip() + + +def response_summary(status: Any, response: dict, named: str | None, body_name: str | None) -> str: + if str(status) == "409": + return ( + "Inspect `X-Comfy-Error-Type`: `concurrency_limit_exceeded` means the original " + "call is still running, so wait for `Retry-After` and reuse the same key; " + "`invalid_input` requires a new key." + ) + if str(status) == "429": + return ( + "Inspect `X-Comfy-Error-Type`: `concurrency_limit_exceeded` means reduce " + "in-flight calls; `rate_limited` means wait for the allowance window." + ) + if named == "RouterRequestError" or body_name == "RouterErrorResponse": + known = { + "400": "Invalid request. Check the error type and request body.", + "401": "Missing or invalid credentials.", + "403": "The request is not allowed for this caller or model.", + "404": "The model ID was not found.", + "413": "The request body is too large.", + "500": "Router could not complete the request.", + "503": "Router is temporarily unavailable. Retry with backoff.", + "504": "The request exceeded a deadline. Check the error type before retrying.", + } + if str(status) in known: + return known[str(status)] + return summary(response.get("description")) + + +def public_endpoint_description(method: str, path: str, description: Any) -> str: + summaries = { + ("GET", "/v2/models"): "List available model IDs and billing facts. Use `next_cursor` while `has_more` is true.", + ("GET", "/v2/models/{provider}/{model}"): "Read details for one model without listing the full catalog.", + ("POST", "/v2/models/{provider}/{model}"): "Run a model and receive its finished result in the same response.", + ("GET", "/v2/models/{provider}/{model}/openapi.json"): "Read one model's input and output schemas as a standalone OpenAPI document.", + } + return summaries.get((method, path), lead(description)) + + +def public_schema_description(name: str, description: Any) -> str: + summaries = { + "RouterModelInput": "The model input object. Read the selected model's OpenAPI document for fields and validation.", + "RouterModelOutput": "The model result object. Read the selected model's output schema for its exact shape.", + "RouterModelInputSchemaDocument": "A standalone OpenAPI document for one model's input and output.", + "RouterModelId": "The model ID used in `POST /v2/models/{provider}/{model}`.", + "RouterProviderSegment": "The provider portion of a `{provider}/{model}` model ID.", + "RouterModelSegment": "The model portion of a `{provider}/{model}` model ID.", + "RouterPageCursor": "An opaque catalog cursor. Pass it back unchanged as `cursor`.", + "RouterModelListEntry": "A model's ID and billing facts.", + "RouterModelDetailFields": "Optional fields returned by the model-details endpoint.", + "RouterModelBilling": "Billing behavior to check before invoking a model. It does not include prices or usage.", + "RouterChargesOnPolicyRejection": "Whether a content-policy refusal is charged for this model. Treat an unknown value as potentially charged.", + "RouterErrorType": "Machine-readable Router error category, also sent in the `X-Comfy-Error-Type` header.", + "RouterErrorResponse": "Error body for authentication, access, model lookup, quota, and provider transport failures.", + "RouterValidationErrorContext": "Provider-supplied details about the validation rule that failed.", + "RouterValidationErrorInput": "The rejected input value, when the provider includes it.", + "RouterValidationErrorDetail": "One field-level validation failure.", + "RouterValidationErrorResponse": "The `422` validation error body. Read `X-Comfy-Error-Type` for its category.", + } + return summaries.get(name, lead(description)) + + +def public_parameter_description(path: str, name: str, description: Any) -> str: + descriptions = { + ("/v2/models/{provider}/{model}", "provider"): "Provider portion of the canonical `{provider}/{model}` model ID.", + ("/v2/models/{provider}/{model}", "model"): "Model portion of the canonical `{provider}/{model}` model ID.", + ("/v2/models/{provider}/{model}/openapi.json", "provider"): "Provider portion of the canonical `{provider}/{model}` model ID.", + ("/v2/models/{provider}/{model}/openapi.json", "model"): "Model portion of the canonical `{provider}/{model}` model ID.", + } + return descriptions.get((path, name), summary(description)) + + +def public_property_description(parent: str, field: str, description: Any) -> str: + descriptions = { + ("RouterModelDetailFields", "input_schema_url"): "URL of this model's OpenAPI document, including its input and output schemas.", + } + return descriptions.get((parent, field), lead(description)) + + +def type_of(doc: dict, node: Any) -> str: + """Human-readable type for a parameter/property, following one `$ref` hop.""" + name = ref_name(node) + target = deref(doc, node) + if not isinstance(target, dict): + return "any" + if name is not None and name.startswith("Router"): + return schema_link(name) + if "allOf" in target: + return " & ".join(type_of(doc, sub) for sub in target["allOf"]) + kind = target.get("type") + if kind == "array": + return f"array of {type_of(doc, target.get('items', {}))}" + if kind is None: + return "any" + if kind == "object" and target.get("additionalProperties") is True: + return "object (open)" + return str(kind) + + +def constraints_of(doc: dict, node: Any) -> str: + """Validation rules for the human reference, with common patterns named plainly.""" + target = deref(doc, node) + if not isinstance(target, dict): + return "" + parts = [] + pattern = target.get("pattern") + if pattern == r"^[a-z0-9]+([._-][a-z0-9]+)*$": + if ref_name(node) == "RouterProviderSegment": + parts.append("Alphanumeric slug, e.g. `anthropic`") + else: + parts.append("Alphanumeric slug, e.g. `claude-opus-4-6`") + elif pattern == r"^[a-z0-9]+([._-][a-z0-9]+)*/[a-z0-9]+([._-][a-z0-9]+)*$": + parts.append("Model ID, e.g. `anthropic/claude-opus-4-6`") + elif pattern == r"^[A-Za-z0-9._~+/=-]+$": + parts.append("Opaque cursor returned as `next_cursor`") + elif pattern == r"^https://": + parts.append("HTTPS URL, e.g. `https://api.comfy.org/v2/models/bfl/flux-2-pro/openapi.json`") + elif pattern: + parts.append(f"`pattern: {pattern}`") + if target.get("format") == "uri": + if pattern != r"^https://": + parts.append("URI") + elif "format" in target: + parts.append(str(target["format"])) + min_length, max_length = target.get("minLength"), target.get("maxLength") + if min_length is not None and max_length is not None: + parts.append(f"{min_length}–{max_length} characters") + elif max_length is not None: + parts.append(f"Up to {max_length} characters") + elif min_length is not None: + parts.append(f"At least {min_length} character{'s' if min_length != 1 else ''}") + minimum, maximum = target.get("minimum"), target.get("maximum") + if minimum is not None and maximum is not None: + parts.append(f"{minimum}–{maximum}") + elif maximum is not None: + parts.append(f"Up to {maximum}") + elif minimum is not None: + parts.append(f"At least {minimum}") + if "default" in target: + parts.append(f"Default: {target['default']}") + return ", ".join(parts) + + +def is_internal(op: dict) -> bool: + """Whether an operation is withheld from public output. + + The same marker the public v2 spec projection honours + (`_is_internal_operation` in api/v2/scripts/project_spec.py), so an + operation withheld from one public artifact is withheld from both. Without + it the reference had no readiness marker at all: tag membership was the only + selector, and a route can be in the contract before it is servable. + """ + tags = op.get("tags") + return op.get("x-internal") is True or (isinstance(tags, list) and "internal" in tags) + + +def merged_parameters(doc: dict, item: dict, op: dict) -> list[Any]: + """An operation's parameters, including the ones it inherits from its path. + + OpenAPI lets a parameter shared by every operation under a path be declared + once on the Path Item. Reading only `op.parameters` would silently drop such + a parameter from the reference, and the drift gate could not catch it because + it compares this generator's output only against itself. Keyed by + `(name, in)` so an operation's own declaration overrides the inherited one, + which is the precedence OpenAPI specifies. + """ + merged: dict[tuple[Any, Any], Any] = {} + for source in ((item.get("parameters") or []), (op.get("parameters") or [])): + for node in source: + resolved = deref(doc, node) + if not isinstance(resolved, dict): + continue + merged[(resolved.get("name"), resolved.get("in"))] = node + return list(merged.values()) + + +def collect_router_operations(doc: dict) -> list[dict]: + ops: list[dict] = [] + for path, item in (doc.get("paths") or {}).items(): + if not isinstance(item, dict): + continue + for method in HTTP_METHODS: + op = item.get(method) + if not isinstance(op, dict): + continue + if ROUTER_TAG in (op.get("tags") or []) and not is_internal(op): + ops.append( + { + "path": path, + "method": method.upper(), + "op": op, + "parameters": merged_parameters(doc, item, op), + } + ) + # Sorted by path then method so the output is a function of the spec's + # CONTENT, not of its key order -- a re-ordered spec must not show up as a + # drift-gate failure. + ops.sort(key=lambda o: (o["path"], o["method"])) + return ops + + +def reachable_schemas(doc: dict, ops: list[dict]) -> list[str]: + """Component schemas the Router operations reach, transitively, in sorted order.""" + schemas = (doc.get("components") or {}).get("schemas") or {} + seen: set[str] = set() + + def walk(node: Any) -> None: + if isinstance(node, list): + for child in node: + walk(child) + return + if not isinstance(node, dict): + return + name = ref_name(node) + if name is not None: + # Follow the ref wherever it points, not only into + # components/schemas: a Router response is reached as + # `$ref: components/responses/RouterRequestError`, and stopping at + # a section this walk does not recognize is how the whole error + # contract went missing from an earlier draft of this reference. + if name in schemas: + if name in seen: + return + seen.add(name) + target = deref(doc, node) + # A $ref's siblings are ignored by OpenAPI 3.0.2, so the target is + # the only thing on this node worth walking. + walk(target) + return + for child in node.values(): + walk(child) + + for entry in ops: + walk(entry["op"]) + + # The `Router` prefix is this spec's namespace for Router-owned components, + # and it is included WHOLESALE rather than only where reachable. The 422 + # validation body is the case that forces it: `RouterValidationErrorResponse` + # is part of the published error contract but no operation declares a `422` + # yet, so a purely reachability-driven reference would omit the very shape an + # SDK's typed exception hierarchy is built on. + seen.update(name for name in schemas if name.startswith("Router")) + return sorted(seen) + + +def render_properties(doc: dict, name: str, schema: dict, out: list[str]) -> None: + props = schema.get("properties") + if not isinstance(props, dict) or not props: + return + required = set(schema.get("required") or []) + out.append("| Field | Type | Required | Constraints | Description |") + out.append("| --- | --- | --- | --- | --- |") + for field, node in props.items(): + desc = public_property_description(parent=name, field=field, description=(deref(doc, node).get("description") if isinstance(deref(doc, node), dict) else "")) + out.append( + f"| `{cell(field)}` | {type_of(doc, node)} | " + f"{'yes' if field in required else 'no'} | " + f"{cell(constraints_of(doc, node)) or '-'} | {cell(desc) or '-'} |" + ) + out.append("") + + +def render_error_types(doc: dict, out: list[str]) -> None: + schemas = (doc.get("components") or {}).get("schemas") or {} + error_type = schemas.get("RouterErrorType") + if not isinstance(error_type, dict): + raise ValueError("openapi.yml declares no RouterErrorType schema") + buckets = error_type.get("x-comfy-error-types") + if not isinstance(buckets, list) or not buckets: + raise ValueError( + "RouterErrorType carries no x-comfy-error-types: the reference has no " + "source for the bucket meanings, and RouterErrorType is deliberately not " + "an enum, so there is nothing else to read them from" + ) + out.append("## Error buckets") + out.append("") + out.append(mdx(public_schema_description("RouterErrorType", error_type.get("description")))) + out.append("") + for tier, title, blurb in ( + ( + "request", + "Request-level buckets", + "Raised for a request Router accepted and then could not complete.", + ), + ( + "transport", + "Transport-level buckets", + "Raised by Router itself, before or around the call to the model.", + ), + ): + rows = [b for b in buckets if isinstance(b, dict) and b.get("tier") == tier] + if not rows: + continue + out.append(f"### {title}") + out.append("") + out.append(blurb) + out.append("") + out.append("| `error_type` | Meaning |") + out.append("| --- | --- |") + for row in rows: + out.append(f"| `{cell(row.get('value'))}` | {cell(summary(row.get('meaning')))} |") + out.append("") + + +def auth_line(doc: dict, ops: list[dict]) -> str: + """The authentication instruction, derived from the operations' `security`. + + Hardcoding this sentence is how a reference comes to tell an integrator to + send the wrong header: the drift gate compares this generator's output only + against itself, so a hand-written claim about auth is the one statement in + the document nothing checks against the contract. It said "bearer API key" + while the spec declared a JWT bearer scheme, which sends a `comfyui-` key -- + an `X-API-Key` credential -- to the bearer validator for a 401 on the + integrator's first call. + """ + schemes = (doc.get("components") or {}).get("securitySchemes") or {} + default = doc.get("security") + + requirements = { + tuple( + sorted( + name + for requirement in (entry["op"].get("security", default) or []) + if isinstance(requirement, dict) + for name in requirement + ) + ) + for entry in ops + } + # OpenAPI reads `security` as OR-of-entries, AND-of-keys-within-an-entry. + # The comprehension above flattens BOTH levels, so a single entry naming two + # schemes -- both credentials required -- would come out of the join below + # as "Send `A` or `B`.", telling an integrator one credential is enough when + # the contract demands both. Every entry in this spec names exactly one + # scheme today, so the published sentence is correct; refusing the + # conjunction keeps it correct after the next contract edit rather than + # silently downgrading it, which is the same rule the rest of this function + # already follows for a requirement it cannot phrase. + for entry in ops: + for requirement in entry["op"].get("security", default) or []: + if isinstance(requirement, dict) and len(requirement) > 1: + raise ValueError( + "a Comfy Router operation requires several security schemes at once " + f"({sorted(requirement)}); auth_line can only phrase alternatives, and " + "rendering a conjunction as 'or' tells an integrator one credential is " + "enough" + ) + if len(requirements) != 1 or not next(iter(requirements)): + raise ValueError( + "the Comfy Router operations do not share one security requirement, so a " + "single authentication sentence cannot describe them all; render the " + f"instruction per operation instead (found: {sorted(requirements)})" + ) + + instructions = [] + for name in next(iter(requirements)): + scheme = schemes.get(name) + if not isinstance(scheme, dict): + raise ValueError( + f"an operation's security names {name!r}, which components.securitySchemes " + "does not declare" + ) + kind = scheme.get("type") + if kind == "http" and str(scheme.get("scheme", "")).lower() == "bearer": + placeholder = str(scheme.get("bearerFormat") or "token").lower() + instructions.append(f"`Authorization: Bearer <{placeholder}>`") + elif kind == "apiKey" and scheme.get("in") == "header": + instructions.append(f"`{scheme.get('name')}: `") + else: + raise ValueError( + f"security scheme {name!r} is a {kind!r}/{scheme.get('in')!r} scheme this " + "generator cannot phrase; teach auth_line to describe it rather than " + "shipping an authentication sentence the contract does not check" + ) + + return "Every endpoint below is authenticated. Send " + " or ".join(instructions) + "." + + +def render(doc: dict) -> str: + ops = collect_router_operations(doc) + if not ops: + raise ValueError(f"no operation carries the {ROUTER_TAG!r} tag") + + schemas = (doc.get("components") or {}).get("schemas") or {} + headers = (doc.get("components") or {}).get("headers") or {} + responses = (doc.get("components") or {}).get("responses") or {} + + tag_desc = "" + for tag in doc.get("tags") or []: + if isinstance(tag, dict) and tag.get("name") == ROUTER_TAG: + tag_desc = mdx(lead(tag.get("description"))) + + out: list[str] = [FRONTMATTER, "", BANNER, "", '
', ""] + if tag_desc: + out += [tag_desc, ""] + + servers = doc.get("servers") or [] + if servers and isinstance(servers[0], dict) and servers[0].get("url"): + out += [f"Base URL: `{servers[0]['url']}`", ""] + + out += [ + auth_line(doc, ops), "", + "Comfy API keys can also be sent as Bearer tokens. `X-API-Key` takes precedence " + "when both credential headers are supplied. See [authentication headers]" + "(/development/comfy-router/headers#request-headers) for the key/JWT distinction " + "and [the Quickstart](/development/comfy-router/quickstart) for access requirements.", + "", "## Endpoints", "", + ] + + for entry in ops: + op = entry["op"] + out.append(f"### `{entry['method']} {entry['path']}`") + out.append("") + if op.get("summary"): + out.append(f"**{mdx(str(op['summary']).strip())}**") + out.append("") + body = mdx(public_endpoint_description(entry["method"], entry["path"], op.get("description"))) + if body: + out += [body, ""] + + params = entry["parameters"] + if params: + out.append("**Parameters**") + out.append("") + out.append("| Name | In | Required | Type | Constraints | Description |") + out.append("| --- | --- | --- | --- | --- | --- |") + for param_ref in params: + param = deref(doc, param_ref) + if not isinstance(param, dict): + continue + out.append( + f"| `{cell(param.get('name'))}` | {cell(param.get('in'))} | " + f"{'yes' if param.get('required') else 'no'} | " + f"{type_of(doc, param.get('schema', {}))} | " + f"{cell(constraints_of(doc, param.get('schema', {}))) or '-'} | " + f"{cell(public_parameter_description(entry['path'], str(param.get('name')), param.get('description'))) or '-'} |" + ) + out.append("") + + # Dereferenced: a body declared as `$ref: components/requestBodies/...` is + # valid OpenAPI, and reading it raw would render an empty "Request body" + # section -- no media type, no schema, no `required` -- instead of failing. + request_body = deref(doc, op.get("requestBody")) + if isinstance(request_body, dict): + out.append("**Request body**") + out.append("") + for media, media_obj in (request_body.get("content") or {}).items(): + schema_ref = (media_obj or {}).get("schema", {}) + out.append( + f"`{media}` -- {type_of(doc, schema_ref)}" + f"{' (required)' if request_body.get('required') else ''}" + ) + desc = mdx(lead(request_body.get("description"))) + if desc: + out += ["", desc] + out.append("") + + out.append("**Responses**") + out.append("") + out.append("| Status | Body | Headers | Description |") + out.append("| --- | --- | --- | --- |") + for status, response_ref in (op.get("responses") or {}).items(): + named = ref_name(response_ref) + response = deref(doc, response_ref) + if not isinstance(response, dict): + continue + body_cell = "-" + body_name = None + for media_obj in (response.get("content") or {}).values(): + body_schema = (media_obj or {}).get("schema", {}) + body_name = ref_name(body_schema) + body_cell = type_of(doc, body_schema) + break + header_names = ", ".join( + f"`{cell(h)}`" + (" (when `concurrency_limit_exceeded`)" if str(status) == "409" and h == "Retry-After" else "") + for h in (response.get("headers") or {}) + ) + desc = cell(response_summary(status, response, named, body_name)) + if named and not desc: + desc = f"Shared `{cell(named)}` response." + out.append( + f"| `{cell(status)}` | {body_cell} | {header_names or '-'} | {desc or '-'} |" + ) + out.append("") + + out += [ + "Table descriptions are brief. Use [Using the Comfy Router API]" + "(/development/comfy-router/models) for model selection, validation, retries, and billing, " + "and [Headers](/development/comfy-router/headers) for header behavior.", + "", + ] + + render_error_types(doc, out) + + # Keyed by the WIRE name -- the key in a response's `headers` map -- and not by + # the component name. An OpenAPI Header Object carries no `name` field, so + # reading one off the component yielded `RouterErrorTypeHeader` where the + # per-operation Responses tables in this same document correctly render + # `X-Comfy-Error-Type`: a reference that contradicted itself and told an + # integrator to read a header no response ever sets. + used_headers: dict[str, str] = {} + + def note_headers(response: Any, router_only: bool) -> None: + if not isinstance(response, dict): + return + for wire, header in (response.get("headers") or {}).items(): + name = ref_name(header) + if name is None or name not in headers: + continue + if router_only and not name.startswith("Router"): + continue + used_headers.setdefault(str(wire), name) + + for entry in ops: + for response in (entry["op"].get("responses") or {}).values(): + note_headers(deref(doc, response), router_only=False) + for shared in responses.values(): + note_headers(shared, router_only=True) + + if used_headers: + out += ["## Response headers", "", "| Header | Type | Description |", "| --- | --- | --- |"] + for wire in sorted(used_headers): + header = headers[used_headers[wire]] + out.append( + f"| `{cell(wire)}` | " + f"{type_of(doc, header.get('schema', {}))} | " + f"{cell(summary(header.get('description'))) or '-'} |" + ) + out.append("") + + out += [RESULT_ASSETS, ""] + + out += [ + '', + "", + "## Per-model input and output schemas", + "", + "Read each model's fields from `GET /v2/models/{provider}/{model}/openapi.json`. " + "The operation's `requestBody` describes input validation; its `200` response " + "describes the output shape and media type when authored. When " + "`x-comfy-input-schema-authored` is false, Router accepts any JSON object " + "without model-specific prevalidation. Provider requirements still apply. " + "The output schemas describe results; Router does not validate returned " + "provider payloads against them. An unauthored output may use `*/*` rather " + "than `application/json`; inspect the response content type before decoding it.", + "", + "## Schemas", + "", + ] + for name in reachable_schemas(doc, ops): + schema = schemas[name] + out.append(f"### {name}") + out.append("") + desc = mdx(public_schema_description(name, schema.get("description"))) + if desc: + out += [desc, ""] + composed = [ + ref_name(sub) for sub in schema.get("allOf", []) if ref_name(sub) is not None + ] + if composed: + out += ["Composes " + ", ".join(schema_link(c) for c in composed) + ".", ""] + constraints = constraints_of(doc, {"$ref": f"#/components/schemas/{name}"}) + kind = schema.get("type") + if kind and not schema.get("properties"): + out += [f"Type: `{kind}`" + (f" -- {constraints}" if constraints else ""), ""] + render_properties(doc, name, schema, out) + + # Exactly one trailing newline: a generated file the drift gate byte-compares + # must not depend on an editor's whitespace habits. + return "\n".join(out).rstrip("\n") + "\n" + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("spec", help="path to the public router-openapi.yaml snapshot") + parser.add_argument("out", help="path of the generated reference") + parser.add_argument( + "--check", + action="store_true", + help="do not write; exit non-zero if the committed file is not what this run produces", + ) + args = parser.parse_args(argv) + + with open(args.spec, encoding="utf-8") as f: + doc = yaml.safe_load(f) + + rendered = render(doc) + + out_path = pathlib.Path(args.out) + if args.check: + current = out_path.read_text(encoding="utf-8") if out_path.exists() else "" + if current != rendered: + print( + f"{args.out} is stale: it is not what the current contract generates.\n" + f"Regenerate it and commit the result:\n" + f" python3 {pathlib.Path(__file__).name} {args.spec} {args.out}", + file=sys.stderr, + ) + return 1 + print(f"{args.out} is up to date with {args.spec}") + return 0 + + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(rendered, encoding="utf-8") + print(f"wrote {args.out} ({len(rendered.splitlines())} lines) from {args.spec}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/.github/scripts/router/gen_router_reference_test.py b/.github/scripts/router/gen_router_reference_test.py new file mode 100644 index 000000000..8703258a5 --- /dev/null +++ b/.github/scripts/router/gen_router_reference_test.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +"""Tests for gen_router_reference.py. + +Run: python3 .github/scripts/router/gen_router_reference_test.py +""" + +from __future__ import annotations + +import importlib.util +import pathlib +import re +import unittest + +import yaml + +_HERE = pathlib.Path(__file__).resolve().parent +_SPEC = _HERE.parents[2] / "router-openapi.yaml" +_REFERENCE = _HERE.parents[2] / "development/comfy-router/reference.mdx" + +_spec = importlib.util.spec_from_file_location("gen_router_reference", _HERE / "gen_router_reference.py") +gen = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(gen) + + +def _minimal_doc(**overrides) -> dict: + doc = { + "servers": [{"url": "https://api.example.test"}], + "tags": [{"name": gen.ROUTER_TAG, "description": "Router routes."}], + "paths": { + "/v1/things/{id}": { + "get": { + "summary": "Read a thing.", + "description": "Statement.\nRationale nobody reading a reference wants.", + "tags": [gen.ROUTER_TAG], + "security": [{"BearerAuth": []}], + "parameters": [{"$ref": "#/components/parameters/ThingId"}], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/RouterThing"} + } + }, + }, + "404": {"$ref": "#/components/responses/RouterRequestError"}, + }, + }, + "post": { + "summary": "Not a Router route.", + "tags": ["API Nodes"], + "security": [{"BearerAuth": []}], + "responses": {"200": {"description": "OK"}}, + }, + } + }, + "components": { + "securitySchemes": { + "BearerAuth": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}, + "ApiKeyAuth": {"type": "apiKey", "in": "header", "name": "X-API-Key"}, + }, + "headers": { + "RouterRequestIdHeader": { + "description": "An id.", + "schema": {"type": "string"}, + } + }, + "parameters": { + "ThingId": { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "string", "maxLength": 8}, + } + }, + "responses": { + "RouterRequestError": { + "description": "A Router failure.", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/RouterErrorResponse"} + } + }, + } + }, + "schemas": { + "RouterThing": {"type": "object", "properties": {"name": {"type": "string"}}}, + "RouterErrorResponse": { + "type": "object", + "properties": {"error_type": {"$ref": "#/components/schemas/RouterErrorType"}}, + "required": ["error_type"], + }, + "RouterErrorType": { + "type": "string", + "description": "A bucket.", + "x-comfy-error-types": [ + {"value": "invalid_input", "tier": "request", "meaning": "Bad input."}, + {"value": "internal_error", "tier": "transport", "meaning": "We broke."}, + ], + }, + "NotARouterSchema": {"type": "object"}, + }, + }, + } + doc.update(overrides) + return doc + + +class LeadParagraph(unittest.TestCase): + def test_stops_at_the_first_newline(self): + # A folded YAML scalar has already collapsed intra-paragraph breaks into + # spaces, so a surviving newline is a paragraph boundary. + self.assertEqual(gen.lead("Statement here.\nRationale."), "Statement here.") + + def test_normalizes_whitespace(self): + self.assertEqual(gen.lead(" a b \t c "), "a b c") + + def test_tolerates_a_missing_description(self): + self.assertEqual(gen.lead(None), "") + + +class Selection(unittest.TestCase): + def test_only_router_tagged_operations_are_rendered(self): + out = gen.render(_minimal_doc()) + self.assertIn("### `GET /v1/things/{id}`", out) + self.assertNotIn("Not a Router route.", out) + + def test_error_components_survive_a_response_level_ref(self): + # The 404 reaches RouterErrorResponse through components/responses, not + # through components/schemas. A walk that stops at an unrecognized + # component section drops the whole error contract. + out = gen.render(_minimal_doc()) + self.assertIn("### RouterErrorResponse", out) + self.assertIn("### RouterErrorType", out) + + def test_non_router_schemas_are_excluded(self): + self.assertNotIn("NotARouterSchema", gen.render(_minimal_doc())) + + def test_rationale_is_dropped_from_operation_prose(self): + out = gen.render(_minimal_doc()) + self.assertIn("Statement.", out) + self.assertNotIn("Rationale nobody reading", out) + + def test_output_is_stable_under_reordered_paths(self): + doc = _minimal_doc() + reordered = _minimal_doc() + reordered["paths"] = { + "/v1/zzz": { + "get": { + "summary": "Another.", + "tags": [gen.ROUTER_TAG], + "security": [{"BearerAuth": []}], + "responses": {"200": {"description": "OK"}}, + } + }, + **doc["paths"], + } + first = gen.render(reordered) + reordered["paths"] = {**doc["paths"], **{k: v for k, v in reordered["paths"].items() if k == "/v1/zzz"}} + self.assertEqual(first, gen.render(reordered)) + + +class ErrorTable(unittest.TestCase): + def test_buckets_are_split_by_tier(self): + out = gen.render(_minimal_doc()) + self.assertIn("### Request-level buckets", out) + self.assertIn("| `invalid_input` | Bad input. |", out) + self.assertIn("### Transport-level buckets", out) + self.assertIn("| `internal_error` | We broke. |", out) + + def test_missing_extension_fails_loudly(self): + doc = _minimal_doc() + del doc["components"]["schemas"]["RouterErrorType"]["x-comfy-error-types"] + with self.assertRaises(ValueError): + gen.render(doc) + + def test_no_router_operations_fails_loudly(self): + doc = _minimal_doc() + doc["paths"]["/v1/things/{id}"]["get"]["tags"] = ["API Nodes"] + with self.assertRaises(ValueError): + gen.render(doc) + + +class Withholding(unittest.TestCase): + """A tagged route is not automatically a publishable one.""" + + def test_an_x_internal_operation_is_not_published(self): + doc = _minimal_doc() + doc["paths"]["/v1/things/{id}"]["get"]["x-internal"] = True + # The only other Router operation is gone with it, so the generator + # refuses rather than emitting an endpoint-less reference. + with self.assertRaises(ValueError): + gen.render(doc) + + def test_an_internal_tagged_operation_is_not_published(self): + doc = _minimal_doc() + doc["paths"]["/v1/hidden"] = { + "get": { + "summary": "Hidden route.", + "tags": [gen.ROUTER_TAG, "internal"], + "security": [{"BearerAuth": []}], + "responses": {"200": {"description": "OK"}}, + } + } + out = gen.render(doc) + self.assertNotIn("Hidden route.", out) + self.assertNotIn("/v1/hidden", out) + + +class InheritedParameters(unittest.TestCase): + def test_a_path_item_parameter_reaches_the_table(self): + doc = _minimal_doc() + item = doc["paths"]["/v1/things/{id}"] + # Hoisted to the Path Item, which OpenAPI lets an operation inherit. + item["parameters"] = [item["get"].pop("parameters")[0]] + out = gen.render(doc) + self.assertIn("| `id` | path |", out) + + def test_an_operation_overrides_the_inherited_declaration(self): + doc = _minimal_doc() + item = doc["paths"]["/v1/things/{id}"] + item["parameters"] = [ + {"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}} + ] + out = gen.render(doc) + # The operation's own `$ref`-ed declaration (a string) wins, and the + # parameter is listed exactly once. + self.assertEqual(out.count("| `id` | path |"), 1) + self.assertIn("| `id` | path | yes | string |", out) + + +class Authentication(unittest.TestCase): + """The auth sentence is derived, because nothing else checks it.""" + + def test_a_jwt_bearer_scheme_is_described_as_a_bearer_token(self): + out = gen.render(_minimal_doc()) + self.assertIn("Send `Authorization: Bearer `.", out) + # The old hardcoded sentence called this an API key, which on this + # platform is an X-API-Key credential, not an Authorization bearer one. + self.assertNotIn("bearer API key", out) + + def test_an_api_key_scheme_names_its_own_header(self): + doc = _minimal_doc() + doc["paths"]["/v1/things/{id}"]["get"]["security"] = [{"ApiKeyAuth": []}] + self.assertIn("Send `X-API-Key: `.", gen.render(doc)) + + def test_an_undeclared_scheme_fails_loudly(self): + doc = _minimal_doc() + doc["paths"]["/v1/things/{id}"]["get"]["security"] = [{"NoSuchScheme": []}] + with self.assertRaises(ValueError): + gen.render(doc) + + def test_disagreeing_operations_fail_rather_than_pick_one(self): + doc = _minimal_doc() + doc["paths"]["/v1/other"] = { + "get": { + "summary": "Other.", + "tags": [gen.ROUTER_TAG], + "security": [{"ApiKeyAuth": []}], + "responses": {"200": {"description": "OK"}}, + } + } + with self.assertRaises(ValueError): + gen.render(doc) + + def test_two_schemes_in_one_entry_are_refused_not_joined_with_or(self): + # OpenAPI reads the keys WITHIN one `security` entry as a conjunction: + # this operation requires BOTH credentials. Phrasing that as "Send A or + # B." would tell an integrator one is enough, so it must fail instead. + doc = _minimal_doc() + doc["paths"]["/v1/things/{id}"]["get"]["security"] = [ + {"BearerAuth": [], "ApiKeyAuth": []} + ] + with self.assertRaisesRegex(ValueError, "several security schemes at once"): + gen.render(doc) + + def test_two_separate_entries_stay_an_alternative(self): + # The sibling of the case above: two ENTRIES really are alternatives, + # so "or" is the right word and the guard must not reject them. + doc = _minimal_doc() + doc["paths"]["/v1/things/{id}"]["get"]["security"] = [ + {"BearerAuth": []}, + {"ApiKeyAuth": []}, + ] + self.assertIn( + "Send `X-API-Key: ` or `Authorization: Bearer `.", + gen.render(doc), + ) + + +class Escaping(unittest.TestCase): + """Spec-authored text is interpolated into GFM tables inside MDX.""" + + def test_a_pipe_in_a_pattern_does_not_split_the_row(self): + doc = _minimal_doc() + doc["components"]["parameters"]["ThingId"]["schema"]["pattern"] = "^(fal|openai)/" + row = next( + line for line in gen.render(doc).splitlines() if line.startswith("| `id` |") + ) + self.assertIn("^(fal\\|openai)/", row) + # Six columns, as the header declares - not the seven an unescaped `|` + # in the pattern would have produced. Split on UNESCAPED pipes only, + # which is the split GFM itself performs. + columns = re.split(r"(? c"), "a <b> c") + + +class RequestBody(unittest.TestCase): + def test_a_referenced_request_body_is_dereferenced(self): + doc = _minimal_doc() + doc["components"]["requestBodies"] = { + "ThingInput": { + "required": True, + "description": "The body.", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/RouterThing"} + } + }, + } + } + doc["paths"]["/v1/things/{id}"]["get"]["requestBody"] = { + "$ref": "#/components/requestBodies/ThingInput" + } + out = gen.render(doc) + self.assertIn("`application/json` -- [`RouterThing`](#routerthing) (required)", out) + self.assertIn("The body.", out) + + +class ResponseHeaders(unittest.TestCase): + def test_the_table_carries_the_wire_name_not_the_component_name(self): + doc = _minimal_doc() + doc["paths"]["/v1/things/{id}"]["get"]["responses"]["200"]["headers"] = { + "X-Comfy-Request-Id": {"$ref": "#/components/headers/RouterRequestIdHeader"} + } + out = gen.render(doc) + self.assertIn("| `X-Comfy-Request-Id` | string | An id. |", out) + # An OpenAPI Header Object has no `name`, so the component name is not a + # wire name and must never be presented as one. + self.assertNotIn("| `RouterRequestIdHeader` |", out) + + +class RealSpec(unittest.TestCase): + """The generator against the contract it actually ships against.""" + + @classmethod + def setUpClass(cls): + with open(_SPEC, encoding="utf-8") as f: + cls.doc = yaml.safe_load(f) + cls.out = gen.render(cls.doc) + + def test_every_router_route_appears(self): + for entry in gen.collect_router_operations(self.doc): + self.assertIn(f"### `{entry['method']} {entry['path']}`", self.out) + + def test_the_closed_error_set_appears_with_meanings(self): + # The SIZE of the closed set is not restated here. routererr's + # TestErrorTypeMeaningsMatchClosedSet pins x-comfy-error-types against + # routererr.AllErrorTypes(), which is the only authority on it; a + # literal count in this file is a second copy that goes stale the next + # time a milestone appends a bucket (BE-8478 appended deadline_exceeded + # and this assertion is what failed). What this test owns is that every + # documented bucket reaches the rendered table. + buckets = self.doc["components"]["schemas"]["RouterErrorType"]["x-comfy-error-types"] + values = [b["value"] for b in buckets] + self.assertEqual(len(values), len(set(values)), "a bucket is documented twice") + self.assertEqual( + sorted(b["value"] for b in buckets if b["tier"] == "request"), + sorted( + [ + "invalid_input", + "content_policy_violation", + "provider_error", + "provider_timeout", + "insufficient_credits", + "model_not_found", + ] + ), + "the request-level tier is the six M1 request buckets; everything else is transport", + ) + for bucket in buckets: + self.assertIn(f"| `{bucket['value']}` |", self.out) + + def test_per_model_schemas_are_linked_not_inlined(self): + self.assertIn("GET /v2/models/{provider}/{model}/openapi.json", self.out) + + def test_the_response_header_table_lists_wire_names(self): + for wire in ("X-Comfy-Error-Type", "X-Comfy-Request-Id", "ETag", "Cache-Control"): + self.assertIn(f"| `{wire}` |", self.out) + for component in ("RouterErrorTypeHeader", "RouterRequestIdHeader"): + self.assertNotIn(f"| `{component}` |", self.out) + + def test_the_auth_sentence_matches_the_declared_scheme(self): + # Both credentials are served, so both are declared -- as SEPARATE + # entries, because OpenAPI reads keys within one entry as AND. The + # server reads `X-API-Key` first and falls back to `Authorization` + # (server/middleware/authentication/comfy_firebase_auth.go), which is + # why the published sentence names the key first. + schemes = self.doc["components"]["securitySchemes"] + for entry in gen.collect_router_operations(self.doc): + self.assertEqual( + entry["op"].get("security"), + [{"BearerAuth": []}, {"ApiKeyAuth": []}], + f"{entry['method']} {entry['path']} changed its security requirement", + ) + self.assertEqual(schemes["BearerAuth"]["bearerFormat"], "JWT") + self.assertEqual(schemes["ApiKeyAuth"]["in"], "header") + self.assertEqual(schemes["ApiKeyAuth"]["name"], "X-API-Key") + self.assertIn( + "Send `X-API-Key: ` or `Authorization: Bearer `.", self.out + ) + self.assertNotIn("", self.out) + + def test_the_committed_reference_is_current(self): + self.assertTrue(_REFERENCE.exists(), f"{_REFERENCE} has never been generated") + self.assertEqual( + _REFERENCE.read_text(encoding="utf-8"), + self.out, + "the committed Router reference is stale; regenerate it", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/snippets/README.md b/.github/scripts/snippets/README.md index ef15aae56..8e4fbe426 100644 --- a/.github/scripts/snippets/README.md +++ b/.github/scripts/snippets/README.md @@ -48,14 +48,26 @@ pnpm code-pages:gen --prune # also delete pages whose model has left the cat `code-pages-check.yml` runs the check on any PR touching a spec, a generated page, a synced schema, `docs.json`, the shared Router snippets or the generator. +Cloud syncs only `openapi-v2.yaml`, `router-openapi.yaml`, and `router-schemas/`. +On its `chore/sync-comfy-api-v2-spec` PR, `router-docs-generate.yml` renders the +Router reference, model pages, and navigation in this repository, then commits +them to that same PR. Handwritten guides and `code.yaml` files stay here. + +The publish job requires a docs-repository `PR_GH_TOKEN` with contents write +access. Unlike `GITHUB_TOKEN`, its push triggers the normal PR checks. Configure +that secret and merge this workflow before enabling schema-only sync in Cloud. + +To regenerate the Router reference locally (Python with PyYAML installed): + +```bash +python .github/scripts/router/gen_router_reference.py router-openapi.yaml development/comfy-router/reference.mdx +``` + ## Coverage `--check` fails when a model under `router-schemas/` has no page, not only when -an existing page is stale. That is the gate: the schemas are synced from cloud by -a bot, so a model Router starts serving arrives here on its own, and the first PR -after it lands goes red until the page is generated. Before this existed, the -catalog grew and the sidebar did not — Router served 202 models while the docs -listed 9. +an existing page is stale. The sync PR's generation workflow adds the matching +pages before merge, and this check verifies the resulting commit. The gate can only see models whose schema has been synced. `GET /v2/models` is the full catalog and is ahead of `router-schemas/` (202 vs 162 on 2026-09-04); diff --git a/.github/scripts/snippets/gen-code-pages.ts b/.github/scripts/snippets/gen-code-pages.ts index b2fa2bb72..414cda071 100644 --- a/.github/scripts/snippets/gen-code-pages.ts +++ b/.github/scripts/snippets/gen-code-pages.ts @@ -980,7 +980,7 @@ const orphans = [...new Bun.Glob(`${MODELS_DIR}/*/*/code.mdx`).scanSync({ cwd: R .sort(); for (const rel of orphans) { if (prune && !check) { - rmSync(join(ROOT, dirname(rel)), { recursive: true, force: true }); + rmSync(join(ROOT, rel)); console.log(`pruned ${rel}`); } else { problems.push(`${rel}: no code.yaml spec and no router-schemas document (rerun with --prune to delete it)`); diff --git a/.github/workflows/code-pages-check.yml b/.github/workflows/code-pages-check.yml index ea55e59da..ab8c7d3e6 100644 --- a/.github/workflows/code-pages-check.yml +++ b/.github/workflows/code-pages-check.yml @@ -7,6 +7,9 @@ on: - 'development/comfy-router/models/**/code.mdx' - 'snippets/comfy-router/**' - 'router-schemas/**' + - 'router-openapi.yaml' + - 'development/comfy-router/reference.mdx' + - '.github/scripts/router/**' - 'docs.json' - '.github/scripts/snippets/**' - '.github/workflows/code-pages-check.yml' @@ -16,6 +19,22 @@ permissions: contents: read jobs: + router-reference: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m pip install PyYAML==6.0.2 + - name: Test the reference renderer + run: python .github/scripts/router/gen_router_reference_test.py + - name: Check the generated reference matches its public schema + run: python .github/scripts/router/gen_router_reference.py router-openapi.yaml development/comfy-router/reference.mdx --check + code-pages: runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/.github/workflows/router-docs-generate.yml b/.github/workflows/router-docs-generate.yml new file mode 100644 index 000000000..b35da1fff --- /dev/null +++ b/.github/workflows/router-docs-generate.yml @@ -0,0 +1,122 @@ +name: Generate Router Docs + +on: + pull_request: + branches: [main] + paths: + - 'openapi-v2.yaml' + - 'router-openapi.yaml' + - 'router-schemas/**' + - 'development/comfy-router/models/**' + - 'development/comfy-router/reference.mdx' + - 'docs.json' + +permissions: + contents: read + +concurrency: + group: router-docs-generate-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + generate: + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.head.ref == 'chore/sync-comfy-api-v2-spec' + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + changed: ${{ steps.patch.outputs.changed }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: python -m pip install PyYAML==6.0.2 + - name: Generate and validate pages without write credentials + run: | + python .github/scripts/router/gen_router_reference.py router-openapi.yaml development/comfy-router/reference.mdx + bun run code-pages:gen --prune --validate + python .github/scripts/router/gen_router_reference.py router-openapi.yaml development/comfy-router/reference.mdx --check + bun run code-pages:check + - name: Package only generated changes + id: patch + run: | + git add -A -- development/comfy-router/reference.mdx docs.json ':(glob)development/comfy-router/models/**/code.mdx' + git diff --cached --binary --no-ext-diff > "$RUNNER_TEMP/generated.patch" + if [ -s "$RUNNER_TEMP/generated.patch" ]; then + echo 'changed=true' >> "$GITHUB_OUTPUT" + fi + - uses: actions/upload-artifact@v4 + if: steps.patch.outputs.changed == 'true' + with: + name: router-docs-patch + path: ${{ runner.temp }}/generated.patch + retention-days: 1 + if-no-files-found: error + + publish: + needs: generate + if: needs.generate.outputs.changed == 'true' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # A PAT is required: pushes with GITHUB_TOKEN do not trigger the PR checks. + # This job never runs repository scripts or generated snippets. + - name: Require the docs sync credential + env: + SYNC_TOKEN: ${{ secrets.PR_GH_TOKEN }} + run: | + if [ -z "$SYNC_TOKEN" ]; then + echo '::error::Configure PR_GH_TOKEN in Comfy-Org/docs before enabling Cloud schema-only sync.' + exit 1 + fi + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + - uses: actions/download-artifact@v4 + with: + name: router-docs-patch + path: ${{ runner.temp }}/router-docs + - name: Apply only generated page changes + run: | + git apply --index \ + --include='development/comfy-router/reference.mdx' \ + --include='development/comfy-router/models/*/*/code.mdx' \ + --include='docs.json' \ + "$RUNNER_TEMP/router-docs/generated.patch" + if git ls-files --stage -- development/comfy-router/reference.mdx development/comfy-router/models docs.json | awk '$1 != "100644" { bad = 1 } END { exit !bad }'; then + echo '::error::Generated documentation must contain only regular files.' + exit 1 + fi + git diff --cached --check + git -c user.name='github-actions[bot]' \ + -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \ + -c core.hooksPath=/dev/null commit \ + -m 'Keep Router pages aligned with the synced API schemas' \ + -m 'Tested: Reference freshness and code-page snippet validation' + - name: Push to the same sync PR and trigger its checks + env: + SYNC_TOKEN: ${{ secrets.PR_GH_TOKEN }} + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} + run: | + current=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq '[.state, .head.sha] | join(" ")') + if [ "$current" != "open $EXPECTED_HEAD" ]; then + echo '::notice::The sync PR closed or changed during generation; leaving it untouched.' + exit 0 + fi + auth=$(printf 'x-access-token:%s' "$SYNC_TOKEN" | base64 -w0) + echo "::add-mask::$auth" + git -c core.hooksPath=/dev/null \ + -c "http.https://github.com/.extraheader=AUTHORIZATION: basic $auth" \ + push --force-with-lease="refs/heads/chore/sync-comfy-api-v2-spec:$EXPECTED_HEAD" \ + origin HEAD:refs/heads/chore/sync-comfy-api-v2-spec diff --git a/development/comfy-router/reference.mdx b/development/comfy-router/reference.mdx index aeac3064e..b30b2c62e 100644 --- a/development/comfy-router/reference.mdx +++ b/development/comfy-router/reference.mdx @@ -94,7 +94,7 @@ The partner model's native JSON input, forwarded to the provider unchanged. | Status | Body | Headers | Description | | --- | --- | --- | --- | -| `200` | [`RouterModelOutput`](#routermodeloutput) | `X-Comfy-Request-Id`, `Idempotent-Replayed`, `X-Committed-Spend-Limit`, `X-Committed-Spend-Current`, `X-Committed-Spend-Remaining` | OK - the partner model's native JSON output, returned unchanged. | +| `200` | [`RouterModelOutput`](#routermodeloutput) | `X-Comfy-Request-Id`, `X-Content-Type-Options`, `Idempotent-Replayed`, `X-Committed-Spend-Limit`, `X-Committed-Spend-Current`, `X-Committed-Spend-Remaining` | OK - the partner model's native output, returned unchanged, under the partner's OWN media type. | | `400` | [`RouterErrorResponse`](#routererrorresponse) | `X-Comfy-Error-Type`, `X-Comfy-Request-Id`, `Idempotent-Replayed` | Invalid request. Check the error type and request body. | | `401` | [`RouterErrorResponse`](#routererrorresponse) | `X-Comfy-Error-Type`, `X-Comfy-Request-Id` | Missing or invalid credentials. | | `403` | [`RouterErrorResponse`](#routererrorresponse) | `X-Comfy-Error-Type`, `X-Comfy-Request-Id` | The request is not allowed for this caller or model. | @@ -180,6 +180,7 @@ Raised by Router itself, before or around the call to the model. | `X-Committed-Spend-Current` | integer | The USD cents the caller currently has committed to calls still in flight. | | `X-Committed-Spend-Limit` | integer | The ceiling, in USD cents, on the partner spend the caller may have committed to calls still in flight - money held from the moment a call is admitted and released when that call finishes. | | `X-Committed-Spend-Remaining` | integer | The USD cents of headroom left under the ceiling, floored at zero. | +| `X-Content-Type-Options` | string | Always `nosniff`, on every successful run of a Router model. | ## Result assets diff --git a/router-openapi.yaml b/router-openapi.yaml new file mode 100644 index 000000000..977664535 --- /dev/null +++ b/router-openapi.yaml @@ -0,0 +1,611 @@ +# Comfy Router — public specification. +# +# GENERATED ONE-WAY — DO NOT HAND-EDIT. +# Projected automatically from the canonical Comfy API contract and synced +# by CI. Change the upstream contract, not this public copy. + +openapi: 3.0.2 +info: + title: Comfy Router + description: 'Comfy Router''s public contract: the model catalog and the model-ID-addressed invocation routes, with the error buckets they return. Projected from the canonical Comfy API contract.' + version: '1.0' +servers: +- url: https://api.comfy.org +tags: +- name: Comfy Router + description: Comfy Router's canonical, model-ID-addressed routes. +paths: + /v2/models: + get: + summary: List the models Comfy Router can run. + description: 'Comfy Router''s model catalog - one page of the canonical model IDs that `POST /v2/models/{provider}/{model}` accepts. An SDK calls this on cold start to discover what is runnable, and the `model_not_found` suggestions come from the same catalog, so an ID listed here that then 404s on invocation would be worse than either failure alone. That agreement is structural rather than a promise: an entry''s `provider` and `model` are the two path segments of the invocation route and reference the SAME schema components that route''s path parameters do, and `id` is those two segments joined by `/`.' + operationId: listRouterModels + tags: + - Comfy Router + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/RouterCatalogCursor' + - $ref: '#/components/parameters/RouterCatalogLimit' + responses: + '200': + description: OK - one page of the model catalog. + headers: + X-Comfy-Request-Id: + $ref: '#/components/headers/RouterRequestIdHeader' + content: + application/json: + schema: + $ref: '#/components/schemas/RouterModelListResponse' + '400': + $ref: '#/components/responses/RouterRequestError' + '401': + $ref: '#/components/responses/RouterRequestError' + '403': + $ref: '#/components/responses/RouterRequestError' + '503': + $ref: '#/components/responses/RouterRequestError' + /v2/models/{provider}/{model}: + get: + 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. + operationId: getRouterModel + tags: + - Comfy Router + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/RouterProvider' + - $ref: '#/components/parameters/RouterModel' + responses: + '200': + description: OK - the model's catalog entry. + headers: + X-Comfy-Request-Id: + $ref: '#/components/headers/RouterRequestIdHeader' + content: + application/json: + schema: + $ref: '#/components/schemas/RouterModelDetail' + '401': + $ref: '#/components/responses/RouterRequestError' + '403': + $ref: '#/components/responses/RouterRequestError' + '404': + $ref: '#/components/responses/RouterRequestError' + '503': + $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.' + operationId: runRouterModel + tags: + - Comfy Router + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - $ref: '#/components/parameters/RouterProvider' + - $ref: '#/components/parameters/RouterModel' + - $ref: '#/components/parameters/RouterIdempotencyKey' + requestBody: + required: true + description: The partner model's native JSON input, forwarded to the provider unchanged. + content: + application/json: + schema: + $ref: '#/components/schemas/RouterModelInput' + responses: + '200': + description: 'OK - the partner model''s native output, returned unchanged, under the partner''s OWN media type. For most models that is JSON (`RouterModelOutput`); for a model whose partner answers a generation directly as bytes - the ElevenLabs audio models are the first in the catalog - it is those bytes, and the response carries the partner''s own `Content-Type` (`audio/mpeg`, `audio/wav`, ...) rather than `application/json`. 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. When this response was replayed from the record held against an `Idempotency-Key` rather than produced by running the model again, it carries `Idempotent-Replayed: true` and is not charged a second time.' + headers: + X-Comfy-Request-Id: + $ref: '#/components/headers/RouterRequestIdHeader' + X-Content-Type-Options: + $ref: '#/components/headers/RouterNoSniffHeader' + Idempotent-Replayed: + $ref: '#/components/headers/RouterIdempotentReplayedHeader' + X-Committed-Spend-Limit: + $ref: '#/components/headers/CommittedSpendLimitHeader' + X-Committed-Spend-Current: + $ref: '#/components/headers/CommittedSpendCurrentHeader' + X-Committed-Spend-Remaining: + $ref: '#/components/headers/CommittedSpendRemainingHeader' + content: + application/json: + schema: + $ref: '#/components/schemas/RouterModelOutput' + '*/*': + schema: + type: string + format: binary + '400': + $ref: '#/components/responses/RouterRunRequestError' + '401': + $ref: '#/components/responses/RouterRequestError' + '403': + $ref: '#/components/responses/RouterRequestError' + '404': + $ref: '#/components/responses/RouterRequestError' + '409': + $ref: '#/components/responses/RouterIdempotencyConflict' + '413': + $ref: '#/components/responses/RouterRequestError' + '422': + $ref: '#/components/responses/RouterModelValidationError' + '429': + $ref: '#/components/responses/RouterConcurrencyLimited' + '503': + $ref: '#/components/responses/RouterRequestError' + '504': + $ref: '#/components/responses/RouterDeadlineExceeded' + /v2/models/{provider}/{model}/openapi.json: + get: + summary: Read one partner model's input and output schemas as an OpenAPI document. + description: The per-model input AND output schemas for a single Comfy Router model, served as a standalone OpenAPI document, so a caller - an SDK, a codegen tool, or an agent - can discover a model's arguments, and the shape of what it returns, without reading Comfy's prose docs. It is the discovery mechanism the SDK quickstart depends on. + operationId: getRouterModelInputSchema + tags: + - Comfy Router + security: + - BearerAuth: [] + - ApiKeyAuth: [] + 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 document (RFC 9110 weak comparison; `*` matches any current document) the answer is a bodyless `304` carrying the same `ETag`, otherwise the full document. + schema: + type: string + responses: + '200': + description: OK - the model's input AND output schemas, as a standalone OpenAPI document. + headers: + X-Comfy-Request-Id: + $ref: '#/components/headers/RouterRequestIdHeader' + ETag: + $ref: '#/components/headers/RouterSchemaETagHeader' + Cache-Control: + $ref: '#/components/headers/RouterSchemaCacheControlHeader' + content: + application/json: + schema: + $ref: '#/components/schemas/RouterModelInputSchemaDocument' + '304': + description: Not Modified - the document 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/RouterSchemaETagHeader' + Cache-Control: + $ref: '#/components/headers/RouterSchemaCacheControlHeader' + '401': + $ref: '#/components/responses/RouterRequestError' + '403': + $ref: '#/components/responses/RouterRequestError' + '404': + $ref: '#/components/responses/RouterRequestError' + '500': + $ref: '#/components/responses/RouterRequestError' + '503': + $ref: '#/components/responses/RouterRequestError' +components: + schemas: + RouterChargesOnPolicyRejection: + type: string + 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. + example: unknown + RouterErrorResponse: + type: object + 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: + type: string + 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' + 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 fifteen 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` and `rate_limited`.' + example: invalid_input + x-comfy-error-types: + - value: invalid_input + tier: request + 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. + - value: content_policy_violation + tier: request + meaning: 'The provider refused the request on content-policy grounds. The refusal is deterministic: re-sending the same input will be refused again.' + - value: provider_error + tier: request + meaning: The partner provider reported a failure of its own, or returned a response Router could not interpret as a result. + - value: provider_timeout + tier: request + 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.' + - value: insufficient_credits + tier: request + meaning: The calling workspace does not have enough credits to run the model. + - value: model_not_found + tier: request + meaning: The `{provider}/{model}` ID names no model Router can run; an unknown provider lands here too. `detail` carries up to three suggestions drawn from the models the caller is entitled to see. + - value: unauthorized + tier: transport + meaning: The request carried no usable credential. + - value: forbidden + tier: transport + meaning: The credential is valid but is not entitled to this model or this operation. + - value: concurrency_limit_exceeded + tier: transport + 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.' + - value: client_disconnected + tier: transport + 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.' + - value: internal_error + tier: transport + 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. + - value: deadline_exceeded + tier: transport + 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.' + - 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".' + - value: rate_limited + tier: transport + 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.' + RouterModelBilling: + type: object + description: Per-model billing FACTS a caller needs before invoking - not prices. Usage and cost figures never appear here. + properties: + charges_on_policy_rejection: + $ref: '#/components/schemas/RouterChargesOnPolicyRejection' + required: + - charges_on_policy_rejection + RouterModelDetail: + type: object + 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.' + allOf: + - $ref: '#/components/schemas/RouterModelListEntry' + - $ref: '#/components/schemas/RouterModelDetailFields' + RouterModelDetailFields: + type: object + 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.' + properties: + input_schema_url: + type: string + format: uri + pattern: ^https:// + maxLength: 2048 + 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.' + RouterModelId: + type: string + 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. + pattern: ^[a-z0-9]+([._-][a-z0-9]+)*/[a-z0-9]+([._-][a-z0-9]+)*$ + maxLength: 193 + example: bfl/flux-2-pro + RouterModelInput: + type: object + description: 'A partner model''s native JSON input document, forwarded to the provider as-is. Its concrete shape is owned by the partner rather than by Comfy, so this is an open object: Router does not narrow, rename, or re-envelope the fields. It is a named component (never an inline anonymous object) because ComfyUI''s spec-driven codegen needs a class to generate.' + additionalProperties: true + RouterModelInputSchemaDocument: + type: object + description: A standalone OpenAPI document describing ONE Comfy Router model's input AND output - the body `POST /v2/models/{provider}/{model}` accepts for that model, under the operation's `requestBody`, and the body it returns, under that operation's `200` content. It is what `GET /v2/models/{provider}/{model}/openapi.json` returns. The component keeps its historical name, which predates the output half; the shape it describes is the whole document, not the input alone. + additionalProperties: true + RouterModelListEntry: + type: object + 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.' + properties: + id: + $ref: '#/components/schemas/RouterModelId' + provider: + $ref: '#/components/schemas/RouterProviderSegment' + model: + $ref: '#/components/schemas/RouterModelSegment' + billing: + $ref: '#/components/schemas/RouterModelBilling' + required: + - id + - provider + - model + - billing + RouterModelListResponse: + type: object + description: One page of the Router model catalog. + properties: + data: + type: array + description: The models on this page, at most `limit` of them. + items: + $ref: '#/components/schemas/RouterModelListEntry' + has_more: + type: boolean + description: Whether another page exists beyond this one. Keep walking while this is true; do not infer the end of the catalog from a short or empty `data`. + next_cursor: + $ref: '#/components/schemas/RouterPageCursor' + limit: + type: integer + description: The page size actually served. A requested `limit` above the maximum is CLAMPED down to the maximum rather than rejected, so this can be smaller than the value asked for - paginate with this number, not with the one you sent, or you will assume rows you never received. + minimum: 1 + maximum: 100 + example: 20 + required: + - data + - has_more + - limit + RouterModelOutput: + type: object + description: 'A partner model''s native JSON output document, returned to the caller as-is. Its concrete shape is owned by the partner rather than by Comfy, so this is an open object: Router does not narrow, rename, or re-envelope the fields. It is a named component (never an inline anonymous object) because ComfyUI''s spec-driven codegen needs a class to generate. For the concrete shape ONE model returns, read that model''s own document at `GET /v2/models/{provider}/{model}/openapi.json`, whose `200` carries the per-model output schema when Comfy has described it.' + additionalProperties: true + RouterModelSegment: + type: string + description: Lowercase `model` segment of the canonical `{provider}/{model}[/{variant}]` model ID - the model to run within that provider. Shared by the invocation route's `model` path parameter and a catalog entry's `model` field, for the same no-drift reason as `RouterProviderSegment`. + pattern: ^[a-z0-9]+([._-][a-z0-9]+)*$ + maxLength: 128 + example: flux-2-pro + RouterPageCursor: + type: string + description: 'An OPAQUE cursor into a Router list. It is produced by the server and only ever round-tripped: it is not an offset, not a model ID, not ordered, and not stable across catalog rebuilds, so parsing one, incrementing one, or persisting one beyond the walk it came from are all outside the contract. Cursor rather than offset because the catalog is a moving list - an offset walk silently skips or repeats entries when entries are added or removed mid-walk, and a caller cannot tell that it happened.' + pattern: ^[A-Za-z0-9._~+/=-]+$ + minLength: 1 + maxLength: 512 + example: q7Fm2xTn9pLd4RsV + RouterProviderSegment: + type: string + description: Lowercase `provider` segment of the canonical `{provider}/{model}[/{variant}]` model ID - the partner whose model is being addressed. The invocation route's `provider` path parameter and a catalog entry's `provider` field both reference this one schema, which is what keeps the listed IDs and the accepted IDs from drifting apart. + pattern: ^[a-z0-9]+([._-][a-z0-9]+)*$ + maxLength: 64 + example: bfl + RouterValidationErrorContext: + type: object + description: 'The violated bound for one `RouterValidationErrorDetail`, carried from the provider verbatim - for example `{"limit_value": 8}` alongside `greater_than`, `{"min_width": 512}` alongside `image_too_small`, or `{"max_size_bytes": 10485760}` alongside `file_too_large`. The key set is specific to the provider and the error type, so this is deliberately an open object: narrowing it to a fixed field list, or folding it into the `msg` string, is precisely how a ported integration compiles and then silently loses the branch that read the bound. Absent when the error type carries no bound.' + additionalProperties: true + RouterValidationErrorDetail: + type: object + description: 'One model-level validation failure, in the FastAPI form. `type` carries the SPECIFIC provider reason - `value_error`, `missing`, `image_too_small`, `unsupported_audio_format`, `greater_than`, `file_too_large` and the rest - which is the granularity `RouterErrorType`''s coarse bucket cannot express. It is an open string and not an `enum` for the same reason: the provider vocabulary runs to roughly 48 values across two tiers and grows on the provider''s release cycle, not ours, and an unmodelled value must reach the caller rather than fail deserialization.' + properties: + loc: + type: array + description: Path to the offending field, outermost segment first - for example `["body", "image_url"]`, or `["body", "images", 0]` where an integer indexes into an array. + items: + anyOf: + - type: string + - type: integer + msg: + type: string + description: Human-readable description of this single failure. + type: + type: string + description: Specific, machine-readable reason for this failure, passed through from the provider unchanged. This is the value a typed SDK exception hierarchy branches on; `error_type` on the response header is only its coarse bucket. + example: image_too_small + ctx: + $ref: '#/components/schemas/RouterValidationErrorContext' + input: + $ref: '#/components/schemas/RouterValidationErrorInput' + required: + - loc + - msg + - type + RouterValidationErrorInput: + description: The offending input value, echoed back verbatim so a caller can see what was rejected without re-deriving it from `loc`. Any JSON type - string, number, boolean, array, object or null - so this schema is deliberately left untyped rather than narrowed to an object. Absent when the provider does not echo the input back. + RouterValidationErrorResponse: + type: object + description: 'Router''s model-level `422` body, in the FastAPI form: the request was well-formed enough to reach the model and the model rejected its contents. Note it carries no `error_type` of its own - that is what `X-Comfy-Error-Type` on the response is for, so a client can read the coarse bucket off the header without first deciding which of the two Router error bodies it received.' + properties: + detail: + type: array + description: Every validation failure found on the request, one entry per offending field. + items: + $ref: '#/components/schemas/RouterValidationErrorDetail' + required: + - 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`.' + headers: + X-Comfy-Error-Type: + $ref: '#/components/headers/RouterErrorTypeHeader' + X-Comfy-Request-Id: + $ref: '#/components/headers/RouterRequestIdHeader' + X-Committed-Spend-Limit: + $ref: '#/components/headers/CommittedSpendLimitHeader' + X-Committed-Spend-Current: + $ref: '#/components/headers/CommittedSpendCurrentHeader' + X-Committed-Spend-Remaining: + $ref: '#/components/headers/CommittedSpendRemainingHeader' + content: + application/json: + schema: + $ref: '#/components/schemas/RouterErrorResponse' + RouterDeadlineExceeded: + description: Comfy stopped holding the connection at its own configured bound (`deadline_exceeded`). The body and the two headers are exactly `RouterRequestError`'s; what this adds is the optional `Retry-After`, present when a retry with the same `Idempotency-Key` will collect the generation that is still running rather than dispatch a new one. See the `504` on `POST /v2/models/{provider}/{model}`. + headers: + X-Comfy-Error-Type: + $ref: '#/components/headers/RouterErrorTypeHeader' + X-Comfy-Request-Id: + $ref: '#/components/headers/RouterRequestIdHeader' + Retry-After: + $ref: '#/components/headers/RouterRetryAfterHeader' + content: + application/json: + 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`.' + headers: + X-Comfy-Error-Type: + $ref: '#/components/headers/RouterErrorTypeHeader' + X-Comfy-Request-Id: + $ref: '#/components/headers/RouterRequestIdHeader' + Retry-After: + $ref: '#/components/headers/RouterRetryAfterHeader' + content: + application/json: + 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. + headers: + X-Comfy-Error-Type: + $ref: '#/components/headers/RouterErrorTypeHeader' + X-Comfy-Request-Id: + $ref: '#/components/headers/RouterRequestIdHeader' + Idempotent-Replayed: + $ref: '#/components/headers/RouterIdempotentReplayedHeader' + content: + application/json: + schema: + $ref: '#/components/schemas/RouterValidationErrorResponse' + 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: + X-Comfy-Error-Type: + $ref: '#/components/headers/RouterErrorTypeHeader' + X-Comfy-Request-Id: + $ref: '#/components/headers/RouterRequestIdHeader' + 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: + X-Comfy-Error-Type: + $ref: '#/components/headers/RouterErrorTypeHeader' + X-Comfy-Request-Id: + $ref: '#/components/headers/RouterRequestIdHeader' + Idempotent-Replayed: + $ref: '#/components/headers/RouterIdempotentReplayedHeader' + content: + application/json: + schema: + $ref: '#/components/schemas/RouterErrorResponse' + parameters: + RouterCatalogCursor: + name: cursor + in: query + required: false + description: Opaque pagination cursor. Pass a previous page's `next_cursor` to fetch the next page; omit it for the first page. See `RouterPageCursor` for why the value is opaque and why this route paginates by cursor rather than by offset. + schema: + $ref: '#/components/schemas/RouterPageCursor' + RouterCatalogLimit: + name: limit + in: query + required: false + description: 'Number of models to return in one page. Values above the declared maximum are outside the contract, but this route does not reject them: it serves the maximum instead, and the page size actually served is echoed back as `limit` on the response, so a clamp is always detectable by the caller. Treat the maximum as the real page stride - a client that asks for more and assumes it received more will miss rows. 0 and negative values are also accepted and select the default, which is why no `minimum` is declared: sub-1 is meaningful here, not invalid.' + schema: + type: integer + maximum: 100 + default: 20 + RouterIdempotencyKey: + name: Idempotency-Key + in: header + required: false + description: 'Caller-generated key that makes retrying ONE logical call safe. A call that reached the caller with an answer is recorded against its key for 24 hours, and a retry carrying the same key is answered from that record instead of dispatching - and charging - the provider a second time, marked `Idempotent-Replayed: true`. Keys are scoped to the workspace your credential carries, or to your user when it carries none - so the keyspace is SHARED by every member of a workspace rather than private to one caller. Make a key unique across the whole workspace, not just within your own client: a second member who reuses a key string is answered from the first member''s record, or refused `409` if the request differs. Because the scope follows the CREDENTIAL and not the person, a credential that carries no workspace at all scopes to your user id instead - so retrying one logical call under a different credential can land in a different namespace, where it is dispatched and charged again. Retry with the credential you started with. A keyed request with no authenticated caller is refused `401`. The guarantee is a BILLING one: a key is charged at most once. It is not a promise that a key is dispatched at most once, and it does not make a lost call resumable. Some answers are RECORDED but not replayable for the full 24 hours, and the billing guarantee is the half that always holds: the key stays consumed - the retry never re-runs and never re-charges - but it is answered `409 invalid_input` instead of being served the original body. That happens whenever Comfy does not hold a copy of the response it can still stand behind; a response past the replay size cap and a result addressed by an asset URL Comfy does not host are the two you are most likely to meet. The second is the one worth planning for, because it looks like an ordinary success: which models answer with a Comfy-hosted asset link, how long one stays valid, and what a result carries when an individual asset could not be copied are stated in one place, under Result assets in the API reference, and this paragraph does not restate them. On a model that returns its result on the original call, an answer still holding a partner''s own asset link is replayed for a few minutes - which is where a dropped connection puts an SDK''s automatic same-key re-send, and while the partner''s link is certainly still alive - and refused after that rather than replayed dead. So a prompt retry of a partially re-hosted result behaves exactly like any other replay, and only a later one meets the `409`. That short window is deliberately NOT offered on a model that submits and is polled, because there the partner may have minted the URL long before your call collected it and its remaining life is unknowable - and those models do not need it: a call cut off mid-generation keeps its key holding the generation, so the same-key retry collects the ORIGINAL result rather than a recorded copy of it. A response past the size cap has no window either and is refused from the start. The action on any of these `409 invalid_input` refusals is the same: use a new key. Only an answer a provider actually produced is recorded, though. A refusal Router raises on its own BEFORE dispatching anything - not enabled for you yet (`403`), unknown model (`404`), not entitled to the model (`403`), a body the model''s schema rejects or that names a different model than the path (`422`), a malformed request (`400 invalid_input`) - dispatched nothing and charged nothing, so it RELEASES the key: re-send the SAME key once you are on the rollout ramp or have corrected the request and it runs for real, rather than replaying the refusal or colliding with it as a `409`. That turns on whether a provider was reached, NEVER on the status, so a `400 content_policy_violation` - the partner''s own answer to a call that ran, which some models meter - is recorded and replayed like any other answer. Releasing a refusal that dispatched nothing frees nothing chargeable, so it does not weaken the at-most-once billing guarantee above.' + schema: + type: string + minLength: 1 + maxLength: 255 + example: 6f1a1a6e-6a53-4a5f-9d3a-2b3b0a1f9c21 + RouterModel: + name: model + in: path + required: true + description: Lowercase model segment of the canonical `{provider}/{model}[/{variant}]` model ID - the model to run within that provider. + schema: + $ref: '#/components/schemas/RouterModelSegment' + RouterProvider: + name: provider + in: path + required: true + description: Lowercase provider segment of the canonical `{provider}/{model}[/{variant}]` model ID - the partner whose model is being run. + schema: + $ref: '#/components/schemas/RouterProviderSegment' + headers: + CommittedSpendCurrentHeader: + description: The USD cents the caller currently has committed to calls still in flight. On a `429` this EXCLUDES the refused call, whose commitment was rolled back before the refusal was sent; on an admitted response it INCLUDES the call being answered. Present alongside `X-Committed-Spend-Limit`. + required: false + schema: + type: integer + format: int64 + minimum: 0 + example: 9600 + CommittedSpendLimitHeader: + description: 'The ceiling, in USD cents, on the partner spend the caller may have committed to calls still in flight - money held from the moment a call is admitted and released when that call finishes. It is not a budget, a balance, or any running total of what the caller has spent to date: settling an invoice frees no room under it, and letting an in-flight call finish does. Contrast `X-Concurrency-Limit`, which bounds those same in-flight calls counted as a NUMBER OF CALLS rather than priced. How the ceiling is SIZED is a separate question from what it measures, and it is not tier-independent: the ceiling moves with the account''s lifetime paid spend, off the same thresholds the concurrent-call tier uses, so paying more raises it - see [partner-node concurrency limits](https://docs.comfy.org/tutorials/partner-nodes/concurrency-limits) for that ladder and for the concurrent-call bound that shares this `429`. Present on BOTH outcomes of an enforcing committed-spend gate - the `429` it raises and the success it admits - and absent while the gate is not enforcing, when it declines to decide and lets the call through, or on a `429` raised by the concurrent-call pool instead (a committed-spend `429` carries this trio and drops `X-Concurrency-*`).' + required: false + schema: + type: integer + format: int64 + minimum: 0 + example: 10000 + CommittedSpendRemainingHeader: + description: 'The USD cents of headroom left under the ceiling, floored at zero. It can be positive on a refusal: the refused call cost more than what was left, and a cheaper call would still be admitted. Present alongside `X-Committed-Spend-Limit`.' + required: false + schema: + type: integer + format: int64 + minimum: 0 + example: 400 + 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' + RouterIdempotentReplayedHeader: + description: Present and `true` when this response was served from an `Idempotency-Key`'s record rather than by running the model again. It carries the original call's status, body and content type, and it is not billed a second time - the charge settled when the original completed. The header is ABSENT on a fresh run rather than sent as `false`, so branch on its presence. + required: false + schema: + type: boolean + example: true + RouterNoSniffHeader: + description: Always `nosniff`, on every successful run of a Router model. + required: true + schema: + type: string + enum: + - nosniff + example: nosniff + 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 + schema: + type: string + format: uuid + example: 6f1a1a6e-6a53-4a5f-9d3a-2b3b0a1f9c21 + RouterRetryAfterHeader: + description: 'Seconds to wait before retrying the SAME request with the SAME `Idempotency-Key`. It is set on the two answers such a retry can actually collect from: a `409` carrying `error_type: concurrency_limit_exceeded`, where the original call for that key is still running, and a `deadline_exceeded` `504`, where Comfy stopped holding the connection but still holds a handle to a generation the provider is running. In both cases the value is the interval Router itself would wait before asking again, which is the one honest number this route has for "ask again later". Absent when there is nothing to collect: an unkeyed call, a bound that expired before the provider accepted anything, or a `409` that refuses the key outright instead of asking the caller to wait.' + required: false + schema: + type: integer + minimum: 1 + example: 2 + RouterSchemaCacheControlHeader: + description: Freshness directives for the served schema document. `private` because the route is authenticated - the document itself is not caller-specific, but a shared cache must not hold a response to an authenticated request - and `must-revalidate` so a stale copy is revalidated against the `ETag` rather than served on. + required: false + schema: + type: string + example: private, max-age=300, must-revalidate + RouterSchemaETagHeader: + description: Strong entity tag over the served document's bytes, for `GET /v2/models/{provider}/{model}/openapi.json`. A per-model schema changes rarely and an SDK re-fetches it often, so a caller should store this value and send it back as `If-None-Match` to get a `304` instead of the document. + required: true + schema: + type: string + example: '"6b8c1f2e0a9d4c3b5e7f8a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f"' + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + 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.' + BearerAuth: + type: http + scheme: bearer + 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.'