Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
5ec7962
docs: add Comfy Router API docs and register in navigation
comfyui-wiki Aug 26, 2026
ea9583a
docs: move Comfy Router docs under api-reference/comfy-router
comfyui-wiki Aug 26, 2026
57780de
docs: add cURL quickstart example, Router nav entry, and zh/ja/ko tra…
comfyui-wiki Aug 26, 2026
bdd44c0
docs: fix anchor slugs in zh/ja/ko Router limitations translations
comfyui-wiki Aug 26, 2026
dbe3bfc
docs(router): address CodeRabbit review - em dashes, anchors, i18n, c…
comfyui-wiki Aug 26, 2026
b2bcea1
Merge branch 'main' into docs/comfy-router-docs
comfyui-wiki Aug 26, 2026
d82e56f
docs(router): consolidate bot syncs #1509-#1524 (cloud@2a369ae) into …
mattmillerai Aug 27, 2026
4ed7bed
docs(partner-nodes): pilot Overview/Workflows/Code tabs on Flux.1 Kon…
mattmillerai Aug 27, 2026
d644a75
docs(partner-nodes): rebuild the Kontext pilot as a Code sub-page aft…
mattmillerai Aug 27, 2026
20b45cb
docs(partner-nodes): point the Kontext Code snippets at /v2/models
mattmillerai Aug 27, 2026
9c5c344
docs(partner-nodes): generate Code pages from a per-model code.yaml
mattmillerai Aug 27, 2026
def9dad
docs(partner-nodes): Code pages for every Router-addressable partner …
mattmillerai Aug 27, 2026
0ea700f
docs(partner-nodes): label the Python / TypeScript / cURL tabs on Cod…
mattmillerai Aug 27, 2026
1e95fbb
docs(partner-nodes): put Model ID and Endpoint on separate lines
mattmillerai Aug 27, 2026
95b1f93
docs(partner-nodes): use the Comfy SDKs in the Python and TypeScript …
mattmillerai Aug 27, 2026
461f33f
docs(partner-nodes): Input/Output schema and example sections on Code…
mattmillerai Aug 27, 2026
dce3f7e
docs(partner-nodes): group Code page tail into Schema and Examples se…
mattmillerai Aug 27, 2026
f1b16bc
docs(partner-nodes): say that the variant tabs are linked across the …
mattmillerai Aug 27, 2026
6d87301
docs(partner-nodes): field-by-field schema blocks instead of prose an…
mattmillerai Aug 27, 2026
7b0126f
docs(partner-nodes): one variant selector that switches the whole Cod…
mattmillerai Aug 27, 2026
7188657
docs(partner-nodes): test the schemas against provider specs; fix the…
mattmillerai Aug 28, 2026
9e5eeca
docs(router): add a common Headers page and point every Code page at it
mattmillerai Aug 28, 2026
1f082f9
Merge remote-tracking branch 'origin/main' into docs/router-model-pag…
mattmillerai Aug 28, 2026
11b1b8f
docs(partner-nodes): address CodeRabbit review on the Code pages
mattmillerai Aug 28, 2026
7c941de
docs(router): tighten the Code pages per review — 'Use X with Comfy R…
mattmillerai Aug 28, 2026
9b963b1
Merge remote-tracking branch 'origin/docs/router-model-page-pilot' in…
mattmillerai Aug 28, 2026
9d61010
docs(router): neutral technical-reference wording in Code page summaries
mattmillerai Aug 28, 2026
0f45838
Merge remote-tracking branch 'origin/main' into docs/router-model-pag…
mattmillerai Sep 2, 2026
714e885
docs(partner-nodes): address CodeRabbit review on the merged Code pages
mattmillerai Sep 2, 2026
3c4b337
Merge origin/main into docs/router-model-page-pilot
mattmillerai Sep 3, 2026
7f3a0b2
docs(comfy-router): move the generated Code pages into the developer …
mattmillerai Sep 4, 2026
59fdd70
Merge origin/main (router-schemas from #1540) into the Code-pages branch
mattmillerai Sep 4, 2026
fabcb0b
docs(comfy-router): repoint the nav, link from the tutorials, and sur…
mattmillerai Sep 4, 2026
41f06f4
docs(comfy-router): repoint the 'Call it from code' cards at the move…
mattmillerai Sep 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .github/scripts/snippets/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Partner-model Code pages

Every Router-addressable partner model can carry a `code.mdx` page under
`development/comfy-router/models/<provider>/<model>/`, showing how to call the
model through Comfy Router from Python, TypeScript and cURL.

These pages live in the **developer** section, not under `tutorials/`: the
tutorials tree is for end users driving the nodes in the app, and mixing API
reference into it makes both harder to find. Each tutorial page links across to
its Code page instead.

`code.mdx` is **generated**. The source of truth is the `code.yaml` next to it;
the page shape lives in `gen-code-pages.ts` and nowhere else.

```text
development/comfy-router/models/black-forest-labs/flux-1-kontext/
code.yaml spec: name, variants, example body, result path <- edit this
code.mdx generated from code.yaml <- never edit

tutorials/partner-nodes/black-forest-labs/flux-1-kontext.mdx Overview (hand-written, links to the Code page)
```

## Commands

```bash
pnpm code-pages:gen # regenerate every code.mdx from its code.yaml
pnpm code-pages:check # CI: fail if any code.mdx is stale, and syntax-check the emitted snippets
```

`code-pages-check.yml` runs the check on any PR touching a spec, a generated
page, the shared Router snippets or the generator.

## Schema sections

Every Code page ends with a Schema section (Input, Output) and an Examples
section (Input, Output). They render from `router-schemas/<provider>/<model>.json`,
which is the exact body of `GET https://api.comfy.org/v2/models/<provider>/<model>/openapi.json`
(a standalone OpenAPI document; the spec-sync bot drops these in, do not hand
write them). When the file is absent, or reports
`x-comfy-input-schema-authored: false`, the page falls back to the spec's
`input` / `output` JSON Schema blocks (rendered as the same ParamField /
ResponseField list) and `example` / `result.example`, with a note that Router
has not published the schema yet. Variants that resolve to the same
schema share one block; variants with different schemas get tabs.

## Provider drift check

`pnpm code-pages:check-providers` (`check-provider-schemas.ts`) fetches each
provider's own published API specification (`provider_spec.url` in the
`code.yaml`: BFL and Ideogram publish OpenAPI, Google a discovery document)
and compares the documented `input` / `output` against it: unknown fields,
type, default, enum, bound and required-ness mismatches fail; provider fields
we leave undocumented are warnings (`--strict` makes them errors, `omit:` lists
the deliberate ones). It runs in CI as the `provider-schemas` job, so the
fallback schemas are tested rather than trusted until Router publishes its own.

## Adding a model

1. Create `code.yaml` in the model's directory (copy the Kontext one).
2. Set `variants` to the Router model IDs (`provider/model`, from `GET /v2/models`).
3. Write `summary`: one sentence describing what the model does. It becomes the
opening line of the generated page's body (`API Reference for <name>.
<summary>`) unless `intro` overrides it.
4. Put the smallest request body that produces a result in `example`. A value of
`"@file:<path>"` is read from disk and base64 encoded by every snippet.
5. Set `result.path` to where the output lives in the provider's native
response and `result.example` to a representative response.
6. Run `pnpm code-pages:gen`, add the page to the model's group in `docs.json`,
and link it from the overview's "Use it" cards.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Python, TypeScript and cURL are all emitted from the same `example`, so the
three snippets cannot disagree about the body. `--validate` compiles each
emitted snippet (`py_compile`, `bun build`, `bash -n`); nothing is executed and
nothing is billed. Live verification against Router is a separate, nightly,
credentialed job.
203 changes: 203 additions & 0 deletions .github/scripts/snippets/check-provider-schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
#!/usr/bin/env bun
/**
* Check every code.yaml `input` / `output` schema against the PROVIDER's own
* published API specification, so the fields we document are the fields the
* provider actually accepts and returns.
*
* bun .github/scripts/snippets/check-provider-schemas.ts # report drift, exit 1 on errors
* bun .github/scripts/snippets/check-provider-schemas.ts --strict # also fail on fields we omit
*
* Each spec (or variant) names where its provider publishes the contract:
*
* provider_spec:
* url: https://api.bfl.ai/openapi.json # OpenAPI 3 document, or a Google discovery document
* operation: POST /v1/flux-kontext-pro # request body = this operation's application/json schema
* response_operation: GET /v1/get_result # optional: where the FINAL result shape lives (poll route)
* request: GenerateContentRequest # google-discovery: schema names instead of operations
* response: GenerateContentResponse
* omit: [webhook_url, webhook_secret] # provider fields we deliberately do not document
*
* Errors (exit 1): a documented field the provider does not have; a type, default,
* enum, bound or required-ness that disagrees with the provider. Warnings: provider
* fields we do not document (errors under --strict), and provider shapes that are
* opaque (`{}`) where we document structure.
*/
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";

const ROOT = join(import.meta.dir, "../../..");
const strict = process.argv.includes("--strict");
const verbose = process.argv.includes("--verbose");

type ProviderSpec = { url: string; operation?: string; response_operation?: string; request?: string; response?: string; omit?: string[] };

const FETCH_TIMEOUT_MS = 20_000;
const cache = new Map<string, Promise<any>>();
async function fetchOnce(url: string): Promise<any> {
const r = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
if (!r.ok) throw new Error(`${url}: HTTP ${r.status}`);
return r.json();
}
/** Transient = the request never produced an HTTP status (timeout, DNS, reset). Those are worth one retry; an HTTP error is not. */
const transient = (e: unknown) => !(e instanceof Error) || !/: HTTP \d+$/.test(e.message);
function fetchDoc(url: string): Promise<any> {
// A provider that never answers would otherwise hold this promise open for the job's whole runner limit.
if (!cache.has(url)) cache.set(url, fetchOnce(url).catch((e) => { if (!transient(e)) throw e; return fetchOnce(url); }));
return cache.get(url)!;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// ---- normalise provider schemas (OpenAPI 3 or Google discovery) into plain JSON-schema-ish objects
function normalizer(doc: any) {
const isDiscovery = !!doc.schemas && !doc.openapi;
const comps: Record<string, any> = isDiscovery ? doc.schemas : doc.components?.schemas ?? {};
const seen = new Set<string>();
const norm = (s: any, depth = 0): any => {
if (!s || depth > 12) return {};
if (s.$ref) {
const name = String(s.$ref).split("/").pop()!;
if (seen.has(name) && depth > 6) return { type: "object", opaque: true };
seen.add(name);
return norm(comps[name], depth + 1);
}
if (s.anyOf || s.oneOf) {
const alts = (s.anyOf ?? s.oneOf).map((a: any) => norm(a, depth + 1)).filter((a: any) => a.type !== "null");
if (alts.length === 1) return { ...alts[0], nullable: true, ...(s.default !== undefined ? { default: s.default } : {}) };
// discriminated unions (FLUX 3 Video): union of properties, required = intersection
const props: Record<string, any> = {}; let req: Set<string> | null = null;
for (const a of alts) {
Object.assign(props, a.properties ?? {});
const r = new Set<string>(a.required ?? []); req = req ? new Set([...req].filter((x) => r.has(x))) : r;
}
const types = [...new Set(alts.map((a: any) => a.type).filter(Boolean))];
return { type: types.length === 1 ? types[0] : types, properties: props, required: [...(req ?? [])], union: true, ...(s.default !== undefined ? { default: s.default } : {}) };
}
if (s.allOf) return s.allOf.map((a: any) => norm(a, depth + 1)).reduce((acc: any, d: any) => ({ ...acc, ...d, properties: { ...(acc.properties ?? {}), ...(d.properties ?? {}) }, required: [...(acc.required ?? []), ...(d.required ?? [])] }), {});
const out: any = { type: s.type };
if ((s.type === "object" || s.type === undefined) && !s.properties && !s.items && !s.enum && !s.anyOf) out.opaque = true;
for (const k of ["default", "enum", "minimum", "maximum", "format", "description"]) if (s[k] !== undefined) out[k] = s[k];
if (s.properties) { out.properties = {}; for (const [k, v] of Object.entries<any>(s.properties)) out.properties[k] = norm(v, depth + 1); }
if (s.required) out.required = s.required;
if (s.items) out.items = norm(s.items, depth + 1);
if (isDiscovery && s.type === undefined && s.properties) out.type = "object";
return out;
};
return { norm, isDiscovery, comps };
}

function findOperation(doc: any, op: string) {
const [method, path] = op.split(" ");
const node = doc.paths?.[path]?.[method.toLowerCase()];
if (!node) throw new Error(`operation ${op} not found in ${doc.info?.title ?? "spec"}`);
return node;
}

async function providerShapes(ps: ProviderSpec): Promise<{ input: any; output: any }> {
const doc = await fetchDoc(ps.url);
const { norm, isDiscovery, comps } = normalizer(doc);
if (isDiscovery) {
// Google discovery documents do not express `required`, so required-ness cannot be checked against them.
const pick = (key: "request" | "response") => {
const name = ps[key];
if (!name) throw new Error(`provider_spec.${key} is required: ${ps.url} is a discovery document, which names schemas rather than operations`);
if (!comps[name]) throw new Error(`provider_spec.${key}: \`${name}\` is not a schema in ${ps.url}`);
return { ...norm({ $ref: name }), noRequiredInfo: true };
};
return { input: pick("request"), output: pick("response") };
}
if (!ps.operation) throw new Error(`provider_spec.operation is required: ${ps.url} is an OpenAPI document, which names operations rather than schemas`);
const op = findOperation(doc, ps.operation);
const req = op.requestBody?.content?.["application/json"]?.schema;
const resOp = ps.response_operation ? findOperation(doc, ps.response_operation) : op;
const res = resOp.responses?.["200"]?.content?.["application/json"]?.schema;
return { input: norm(req), output: norm(res) };
Comment thread
mattmillerai marked this conversation as resolved.
}

// ---- comparison
type Report = { errors: string[]; warnings: string[] };
const eq = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
const baseType = (t: unknown) => (Array.isArray(t) ? t : [t]).map(String);
const compatible = (ours: unknown, theirs: unknown) => {
if (theirs === undefined) return true;
const o = baseType(ours), t = baseType(theirs);
return o.some((x) => t.includes(x) || (x === "integer" && t.includes("number")) || (x === "number" && t.includes("integer")));
};

/**
* Collapse a documented `anyOf` / `oneOf` (e.g. `integer | "auto"`) into one comparable shape,
* the way `norm` already collapses the provider's: union of types and properties, intersection
* of `required`, widest bounds. Without this a documented union reads as an untyped field.
*/
function flatten(s: any): any {
const alts: any[] = s?.anyOf ?? s?.oneOf;
if (!Array.isArray(alts) || !alts.length) return s;
const types = [...new Set(alts.flatMap((a) => (Array.isArray(a.type) ? a.type : [a.type])).filter(Boolean))];
const nums = (k: string) => alts.map((a) => a[k]).filter((n) => n !== undefined).map(Number);
const props: Record<string, any> = {}; let req: Set<string> | null = null;
for (const a of alts) {
Object.assign(props, a.properties ?? {});
const r = new Set<string>(a.required ?? []); req = req ? new Set([...req].filter((x) => r.has(x))) : r;
}
const [mins, maxs] = [nums("minimum"), nums("maximum")];
return {
...s, anyOf: undefined, oneOf: undefined,
type: s.type ?? (types.length === 1 ? types[0] : types),
...(mins.length ? { minimum: Math.min(...mins) } : {}),
...(maxs.length ? { maximum: Math.max(...maxs) } : {}),
...(Object.keys(props).length ? { properties: { ...props, ...(s.properties ?? {}) }, required: [...(req ?? [])] } : {}),
};
}

function compare(ours: any, theirs: any, where: string, omit: Set<string>, rep: Report, prefix = "", noRequired = false) {
ours = flatten(ours);
noRequired = noRequired || !!theirs?.noRequiredInfo;
if (!theirs || theirs.opaque) { if (ours?.properties && Object.keys(ours.properties).length) rep.warnings.push(`${where}: provider declares \`${prefix || "body"}\` as an opaque object; cannot verify ${Object.keys(ours.properties).length} documented field(s) beneath it`); return; }
const ourReq = new Set<string>(ours.required ?? []); const theirReq = new Set<string>(theirs.required ?? []);
for (const [name, raw] of Object.entries<any>(ours.properties ?? {})) {
const o = flatten(raw);
const path = prefix ? `${prefix}.${name}` : name;
const t = theirs.properties?.[name];
if (!t) { rep.errors.push(`${where}: \`${path}\` is documented but the provider spec has no such field`); continue; }
if (!compatible(o.type, t.type)) rep.errors.push(`${where}: \`${path}\` type ${JSON.stringify(o.type)} vs provider ${JSON.stringify(t.type)}`);
if (!noRequired && ourReq.has(name) !== theirReq.has(name) && !theirs.union) rep.errors.push(`${where}: \`${path}\` required=${ourReq.has(name)} vs provider required=${theirReq.has(name)}`);
if (t.default !== undefined && o.default !== undefined && !eq(o.default, t.default)) rep.errors.push(`${where}: \`${path}\` default ${JSON.stringify(o.default)} vs provider ${JSON.stringify(t.default)}`);
if (t.default !== undefined && o.default === undefined && !prefix.includes("[]")) rep.warnings.push(`${where}: \`${path}\` provider default ${JSON.stringify(t.default)} is not documented`);
if (o.default !== undefined && t.default === undefined) rep.warnings.push(`${where}: \`${path}\` documents default ${JSON.stringify(o.default)} but the provider spec declares none`);
if (o.enum && t.enum) { const extra = o.enum.filter((v: unknown) => !t.enum.includes(v)); if (extra.length) rep.errors.push(`${where}: \`${path}\` enum values ${JSON.stringify(extra)} are not in the provider's ${JSON.stringify(t.enum)}`); const missing = t.enum.filter((v: unknown) => !o.enum.includes(v) && !String(v).endsWith("UNSPECIFIED")); if (missing.length) rep.warnings.push(`${where}: \`${path}\` provider also allows ${JSON.stringify(missing)}`); }
if (t.enum && !o.enum && t.enum.length <= 12) rep.warnings.push(`${where}: \`${path}\` provider enumerates ${JSON.stringify(t.enum)} but we document a free ${o.type}`);
for (const b of ["minimum", "maximum"] as const) if (o[b] !== undefined && t[b] !== undefined && Number(o[b]) !== Number(t[b])) rep.errors.push(`${where}: \`${path}\` ${b} ${o[b]} vs provider ${t[b]}`);
if (o.properties) compare(o, t, where, omit, rep, path, noRequired);
if (o.items?.properties) compare(o.items, t.items, where, omit, rep, `${path}[]`, noRequired);
}
for (const name of Object.keys(theirs.properties ?? {})) {
const path = prefix ? `${prefix}.${name}` : name;
if (!ours.properties?.[name] && !omit.has(path) && !omit.has(name) && !prefix.includes("[]") && prefix.split(".").length <= 1) {
(strict ? rep.errors : rep.warnings).push(`${where}: provider field \`${path}\` is not documented${theirReq.has(name) ? " (and the provider marks it required)" : ""}`);
}
}
}

// ---- main
const glob = new Bun.Glob("development/comfy-router/models/**/code.yaml");
const rep: Report = { errors: [], warnings: [] };
let checked = 0, skipped = 0;
for (const specPath of glob.scanSync({ cwd: ROOT })) {
const spec = Bun.YAML.parse(readFileSync(join(ROOT, specPath), "utf8")) as any;
for (const v of spec.variants) {
const ps: ProviderSpec | undefined = v.provider_spec ?? spec.provider_spec;
const input = v.input ?? spec.input, output = v.output ?? spec.output;
if (!ps) { skipped++; rep.warnings.push(`${dirname(specPath)} (${v.model}): no provider_spec, cannot verify`); continue; }
const where = `${dirname(specPath).replace("development/comfy-router/models/", "")} (${v.model})`;
try {
const shapes = await providerShapes(ps);
const omit = new Set<string>(ps.omit ?? []);
if (input) compare(input, shapes.input, `${where} input`, omit, rep);
if (output) compare(output, shapes.output, `${where} output`, omit, rep);
checked++;
} catch (e) { rep.errors.push(`${where}: ${(e as Error).message}`); }
}
}
if (verbose) for (const w of rep.warnings) console.log(`warn ${w}`);
else if (rep.warnings.length) console.log(`(${rep.warnings.length} warning(s); run with --verbose to list them)`);
for (const e of rep.errors) console.log(`ERROR ${e}`);
console.log(`\n${checked} model(s) checked against provider specs, ${skipped} skipped, ${rep.errors.length} error(s), ${rep.warnings.length} warning(s)`);
process.exit(rep.errors.length ? 1 : 0);
Loading
Loading