UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

868 lines • 39.8 kB
import { FRONTEND_ONLY_NODE_TYPES } from "./workflow-converter.js"; import { frontendVirtualTypesFor } from "./frontend-virtual-types.js"; import { getObjectInfo } from "../comfyui/client.js"; import { enqueueWorkflow } from "./workflow-executor.js"; import { config, getComfyUIBaseUrl } from "../config.js"; import { ValidationError } from "../utils/errors.js"; /** * API / partner-node support, mirroring `comfy-cli generate` (list / schema / run). * * MECHANISM & ASSUMPTIONS (confirmed against ComfyUI + comfy-cli source): * - These nodes run entirely on the *connected* ComfyUI server via its normal * HTTP API (/object_info + /prompt), so this works against remote servers too. * We reuse the existing client (getObjectInfo) and enqueue helper rather than * re-implementing anything. * - ComfyUI marks hosted partner/API nodes in /object_info with `api_node: true` * (server.py emits this from the node class's `API_NODE` attribute). Their * `category` also conventionally starts with "api node/" (e.g. * "api node/image/BFL", "api node/video/Kling"). We treat EITHER signal as a * match so older/newer ComfyUI builds both work. * Ref: comfyanonymous/ComfyUI server.py node_info() and comfy_api_nodes/*.py. * - AUTH: API nodes require a Comfy account / API key, but that credential is * supplied to the *ComfyUI server* out-of-band — it is injected by the server * into the node's HIDDEN inputs (`auth_token_comfy_org` / `api_key_comfy_org`) * from the logged-in session. The MCP client does NOT (and should not) place * the key in the workflow JSON. If the server isn't authenticated, the job * will be enqueued but fail server-side; we surface that as guidance rather * than trying to pass credentials ourselves. (Lower-confidence area — see * docs.comfy.org/tutorials/api-nodes.) */ const API_CATEGORY_PREFIX = "api node"; const defaultDeps = { getObjectInfo, enqueue: enqueueWorkflow, getFrontendVirtualTypes: () => frontendVirtualTypesFor(getComfyUIBaseUrl()), }; /** True if a node definition is a hosted partner/API node. */ export function isApiNode(def) { if (def.api_node === true) return true; const category = (def.category ?? "").toLowerCase(); return category === API_CATEGORY_PREFIX || category.startsWith(`${API_CATEGORY_PREFIX}/`); } const EXTERNAL_SERVICE_PACKS = [ { module: "comfyui-fal-api", categoryPrefixes: ["fal"], localCategoryPrefixes: ["fal/utils"], provider: "fal.ai", }, { module: "comfyui-pvl-fal-nodes", localCategoryPrefixes: ["fal/utils"], provider: "fal.ai" }, { module: "comfyui_fal_api", localCategoryPrefixes: ["fal/utils"], provider: "fal.ai" }, { module: "comfyui-fal-api-flux", provider: "fal.ai" }, // #1855 — PoYo's OFFICIAL pack (github.com/PoyoAPI/poyo-comfyui) for its hosted // image/video/music/3D API, which requires an account API key created at // poyo.ai/dashboard/api-key. Every generic signal says "free" here: the pack keeps // the credential OUT of its workflow inputs, so declaresCredentialInput() cannot see // it, and api_node is false — so check_runtime called it local while the node spends // the user's balance. The category prefix carries the match through a directory // rename the way the fal entries do. { module: "poyo-comfyui", categoryPrefixes: ["poyo ai"], provider: "PoYo" }, // #2416 — FloyoAI/ComfyUI-Seed-API, direct BytePlus ModelArk nodes. Same shape as // PoYo above: the key lives in config.ini or BYTEPLUS_API_KEY, never in a workflow // input, so declaresCredentialInput() sees nothing and api_node is false — and // check_runtime answered `local` / free for nodes spending the user`s BytePlus balance. // // `Seed/Video` is EXEMPT and is not a typo for Seed/VideoGeneration. It holds one node, // VideoToFrames, which takes a video_url, requests.get()s it and shells out to ffmpeg — // no credential, no ModelArk call, so billing it would report a free node as paid. The // reported fix listed only the three paid prefixes and would have swept this one in // through the `module` match, which catches the whole pack on its own. // // The two prefixes cannot collide: the exemption test is `category === p || // category.startsWith(`${p}/`)`, and "seed/videogeneration" is neither equal to // "seed/video" nor prefixed by "seed/video/". Verified against the pack`s own sources, // not the report: CATEGORY is Seed/ImageGeneration, Seed/VideoGeneration, Seed/Chat and // Seed/Video. { module: "comfyui-seed-api", categoryPrefixes: ["seed/imagegeneration", "seed/videogeneration", "seed/chat"], localCategoryPrefixes: ["seed/video"], provider: "BytePlus", }, // #2543 — Nicole Social env-auth backends (the report's own category is // "Nicole Social/backends"). GEMINI_API_KEY / WAVESPEED_API_KEY live in the // environment, never as workflow inputs, and api_node is absent — so every // generic signal says free while the nodes spend the user's Google Gemini / // WaveSpeed balance. The category prefix carries the match through a directory // rename the way the fal and PoYo entries do. // // Two entries so a graph that uses the pack names BOTH providers rather than a // slash-joined string. They share the match on purpose: /object_info does not // distinguish which env var a class reads. Deliberately NOT a generic "gemini" // / "wavespeed" match — that is the keyword-regex hole #1483 refused, and it // would flag local packs (Impact, KJNodes, Comfy-WaveSpeed's local optimizer) // plus any merely similar category. Official Comfy partner Gemini nodes stay // isApiNode (`api_node: true`) and never enter this list. { module: "nicole-social", categoryPrefixes: ["nicole social"], provider: "Google Gemini", }, { module: "nicole-social", categoryPrefixes: ["nicole social"], provider: "WaveSpeed", }, { module: "nicole_social", provider: "Google Gemini" }, { module: "nicole_social", provider: "WaveSpeed" }, ]; /** * Input names that mean the node authenticates to a service the user pays for. * * Widened past `api_key` (codex P1): a paid node is just as likely to ask for `api_token`, * `client_secret` or camelCase `apiToken`, and `/i` folds case but not word shape — so the * name is normalised to snake_case before matching rather than the pattern being asked to * cover every spelling. * * Bare `secret_key` was tried and REMOVED (codex P2): a local hashing/crypto node * legitimately takes one, and flagging it would report a free graph as `api`. Every term * kept here names a REMOTE-service credential specifically (`api_*`, `client_secret`, * bearer/access/auth tokens), so authentication that is not obviously to a service does * not, on its own, spend the user's confirmation budget. */ const CREDENTIAL_INPUT_RE = /^(?:.*_)?(?:api_key|api_token|api_secret|access_token|auth_token|bearer_token|client_secret)$/; /** camelCase/PascalCase → snake_case, so `apiToken` and `api_token` match the same rule. */ function normalizeInputName(name) { return name .replace(/([a-z0-9])([A-Z])/g, "$1_$2") .toLowerCase() .replace(/[\s-]+/g, "_"); } function declaresCredentialInput(def) { const input = def.input; if (!input || typeof input !== "object") return false; for (const group of [input.required, input.optional]) { if (!group || typeof group !== "object") continue; for (const name of Object.keys(group)) { if (CREDENTIAL_INPUT_RE.test(normalizeInputName(name))) return true; } } return false; } function packMatches(pack, category, pythonModule) { if (pack.module && pythonModule.includes(pack.module)) return true; return (pack.categoryPrefixes?.some((p) => category === p || category.startsWith(`${p}/`)) === true); } /** * Every known paid pack this node belongs to — empty if none. * * EVERY matching entry is considered, and any one that does NOT exempt the node counts * (codex P1). Returning on the first match let `ComfyUI-fal-API-Flux` bind to the broader * `comfyui-fal-api` entry and inherit ITS `FAL/Utils` exemption, so a paid Flux node in * that category answered "free" while the stricter entry written for that very pack was * never reached. Resolving toward PAID is also the correct direction for a money guard: * disagreement between two entries is not evidence of free. * * #2543 — a pack that hosts more than one vendor (Nicole Social bills Google Gemini AND * WaveSpeed from the same category) contributes every distinct provider, so guidance can * name both balances rather than the first entry's alone. */ function matchingPaidPacks(def) { const category = (def.category ?? "").toLowerCase(); const pythonModule = (def.python_module ?? "").toLowerCase(); const paid = []; for (const pack of EXTERNAL_SERVICE_PACKS) { if (!packMatches(pack, category, pythonModule)) continue; const exempt = pack.localCategoryPrefixes?.some((p) => category === p || category.startsWith(`${p}/`)); if (!exempt) paid.push(pack); } return paid; } function externalServicePackFor(def) { return matchingPaidPacks(def)[0] ?? null; } function markerValue(value) { if (value === true) return { marked: true, provider: null }; if (typeof value === "string") { const s = value.trim(); if (!s) return { marked: false, provider: null }; if (s.toLowerCase() === "true") return { marked: true, provider: null }; if (s.toLowerCase() === "false") return { marked: false, provider: null }; return { marked: true, provider: s }; } return { marked: false, provider: null }; } function namedProvider(value) { if (typeof value !== "string") return null; const s = value.trim(); return s.length > 0 ? s : null; } /** * #2543 — explicit opt-in on the node def. Env-only packs cannot put a credential * widget on the workflow just to satisfy classification; they set EXTERNAL_API_NODE * (optionally with a provider) instead. Absence is not evidence of free — the * enumerated registry and credential-input catch still apply. */ function externalApiMark(def) { const rec = def; let marked = false; let provider = null; for (const value of [rec.external_api_node, rec.EXTERNAL_API_NODE, rec.externalApiNode]) { const parsed = markerValue(value); if (!parsed.marked) continue; marked = true; if (parsed.provider) provider = parsed.provider; } if (!marked) return { marked: false, provider: null }; for (const value of [rec.external_api_provider, rec.EXTERNAL_API_PROVIDER, rec.externalApiProvider]) { const named = namedProvider(value); if (named) provider = named; } return { marked, provider }; } /** * True when a node runs on a PAID EXTERNAL SERVICE without carrying Comfy's partner * marker. Never returns true for a node `isApiNode` already claims — the caller keeps the * two lists distinct so a reader can tell a Comfy partner node from a third-party one. */ export function isExternalServiceNode(def) { if (isApiNode(def)) return false; if (externalApiMark(def).marked) return true; if (externalServicePackFor(def)) return true; // The general catch: a node that asks for a service credential cannot be CONFIRMED free, // whichever pack it came from. return declaresCredentialInput(def); } /** Distinct providers that bill for `def`, when a marker or known pack names them. */ function externalServiceProviders(def) { if (isApiNode(def)) return []; const mark = externalApiMark(def); if (mark.provider) return [mark.provider]; const named = []; for (const pack of matchingPaidPacks(def)) { if (!named.includes(pack.provider)) named.push(pack.provider); } return named; } /** The provider that bills for `def`, when a known pack or marker identifies one. */ export function externalServiceProvider(def) { return externalServiceProviders(def)[0] ?? null; } /** * Discover hosted partner/API nodes available on the connected ComfyUI. * Optionally narrow by a case-insensitive substring matched against the * class_type, display name, or category (e.g. "image", "kling", "video"). */ export async function listApiNodes(filter, deps = defaultDeps) { const objectInfo = await deps.getObjectInfo(); const needle = filter?.trim().toLowerCase(); const results = []; for (const [classType, def] of Object.entries(objectInfo)) { if (!def || !isApiNode(def)) continue; const summary = { class_type: classType, display_name: def.display_name || classType, category: def.category ?? "", description: def.description ?? "", output: Array.isArray(def.output) ? def.output : [], deprecated: def.deprecated === true, experimental: def.experimental === true, }; if (needle) { const haystack = `${classType} ${summary.display_name} ${summary.category}`.toLowerCase(); if (!haystack.includes(needle)) continue; } results.push(summary); } results.sort((a, b) => a.class_type.localeCompare(b.class_type)); return results; } /** Collect the ids of every subgraph DEFINITION in a UI-format workflow. A * top-level node whose `type` equals one of these ids is a subgraph INSTANCE — a * local structural grouping, not a real ComfyUI node type — so it must NOT be * classified (it would otherwise look "unknown"/paid). The instance's real nodes * live in the definition and are walked separately. Returns the def map so the * caller can both skip instances and recurse into the definitions. */ function subgraphDefMap(g) { const m = new Map(); const defs = g.definitions?.subgraphs; if (Array.isArray(defs)) { for (const sg of defs) { const id = sg?.id; if (typeof id === "string" && id.length > 0) m.set(id, sg); } } return m; } /** Count of subgraph definitions in a (UI-format) workflow — 0 if none/unknown. */ export function countSubgraphs(graph) { if (!graph || typeof graph !== "object") return 0; return subgraphDefMap(graph).size; } /** * Extract the set of REAL node class_types referenced by a workflow, accepting * BOTH UI/litegraph format (top-level `nodes` array, each with a `type`) and * API/prompt format (top-level numeric keys, each an object with `class_type`). * * SUBGRAPH-AWARE: subgraph instances (a node whose `type` is a subgraph * definition id, i.e. a UUID) are NOT real node types — they're skipped, and the * instance's real inner nodes (from `definitions.subgraphs[].nodes`) are walked * instead (nested subgraphs included, since all definitions live in one flat * list). This keeps subgraph-heavy workflows from being mis-flagged "unknown" * while still classifying any API node nested inside a subgraph. */ export function extractWorkflowClassTypes(graph) { const out = new Set(); if (!graph || typeof graph !== "object") return []; const g = graph; // UI/litegraph format. if (Array.isArray(g.nodes)) { const defs = subgraphDefMap(g); const walk = (nodes) => { if (!Array.isArray(nodes)) return; for (const n of nodes) { const t = n?.type; if (typeof t !== "string" || t.length === 0) continue; if (defs.has(t)) continue; // subgraph instance — structural; its def is walked below out.add(t); } }; walk(g.nodes); for (const sg of defs.values()) walk(sg.nodes); // real nodes inside every subgraph def return [...out]; } // API/prompt format (no subgraph definitions). for (const v of Object.values(g)) { const ct = v?.class_type; if (typeof ct === "string" && ct.length > 0) out.add(ct); } return [...out]; } export async function checkWorkflowRuntime(graph, deps = defaultDeps, opts = {}) { const classTypes = extractWorkflowClassTypes(graph); const objectInfo = await deps.getObjectInfo(); // #1400 — the SECOND source of frontend-only proof, alongside the static // FRONTEND_ONLY_NODE_TYPES: the types a connected panel observed this // frontend's registry prove virtual (`isVirtualNode === true` on the // registered class — covering third-party virtual nodes like rgthree's Label / // Fast Groups toggles that no static list may name). Read ONCE per call; an // absent/unreadable channel is an empty set, and a type it names is still // classified from its /object_info def whenever the server registers one (the // def-first rule below applies to both sources identically). let frontendVirtual; try { frontendVirtual = deps.getFrontendVirtualTypes?.() ?? new Set(); } catch { // A broken source is "no proof", never a failure of the classification it // was only narrowing. frontendVirtual = new Set(); } const apiNodes = []; const externalApiNodes = []; const externalProviders = []; const unknownNodes = []; for (const ct of classTypes) { // A FRONTEND-ONLY NODE IS NOT AN UNKNOWN ONE (#1372). MarkdownNote, Note, Reroute and // PrimitiveNode are LiteGraph-native: the frontend registers them, /object_info never // lists them, and they are stripped before a prompt is queued. // // The "unknown" verdict exists to express a specific doubt — this class_type is absent // from the server's registry, so it COULD be a paid partner node the server does not // expose, and claiming "local/free" would be a guess with someone's money on it. That // caution is right and stays. It just cannot apply to a node that does not execute at // all: a virtual node can no more be a paid API node than it can be a checkpoint // loader, so it earns none of the doubt while collapsing the whole verdict to // "unknown" and stopping the safety flow to ask an unanswerable question. // // The set is IMPORTED from the converter rather than restated here. It already had to // know which types never reach the backend, and a second copy drifting from the first // is exactly how this bug happened. const def = objectInfo[ct]; // …but ONLY when the server does not register it (codex P1). The skip used to run // BEFORE this lookup, which meant a third-party backend node legitimately named `Note` // or `PrimitiveNode` — and registered with api_node:true — was skipped unexamined and // the workflow reported "local / usesApiNodes:false". That tells the agent the run is // confirmed free and skips the credit confirmation, which is the one outcome this // classifier exists to prevent. A safety check that can be bypassed by a name // collision is worse than the false "unknown" it was fixing. // // Absence from /object_info is not a heuristic for "virtual", it is the definition: // the frontend registers these, the backend does not. So a REGISTERED node of the same // name is a real node and is classified as one. The #1400 panel-observed set is // consulted under the SAME rule: it exempts only what the server does not register. if (!def && (FRONTEND_ONLY_NODE_TYPES.has(ct) || frontendVirtual.has(ct))) continue; if (!def) { unknownNodes.push(ct); continue; } if (isApiNode(def)) apiNodes.push(ct); // #1483 — a paid third-party node is not a partner node, but it spends the user's // money just the same, so it must reach the same verdict. else if (isExternalServiceNode(def)) { externalApiNodes.push(ct); for (const provider of externalServiceProviders(def)) { if (!externalProviders.includes(provider)) externalProviders.push(provider); } } } const paidNodes = [...apiNodes, ...externalApiNodes]; const hasApiNodes = paidNodes.length > 0; // "api" only if EVERY classifiable node is an API node; "mixed" if some are. // Virtual nodes are not classifiable EITHER WAY, so they leave the denominator too — // otherwise a workflow of one KSampler plus three Notes reads as 1-of-4 API and reports // "mixed" when it is entirely local. const virtualCount = classTypes.filter((ct) => !objectInfo[ct] && (FRONTEND_ONLY_NODE_TYPES.has(ct) || frontendVirtual.has(ct))).length; const classifiable = classTypes.length - unknownNodes.length - virtualCount; let runtime; let usesApiNodes; if (hasApiNodes) { runtime = paidNodes.length >= classifiable && classifiable > 0 ? "api" : "mixed"; usesApiNodes = true; } else if (unknownNodes.length > 0 && !opts.bundledLocalPack) { // No recognized API nodes, but some class_types aren't in /object_info — they // COULD be paid API/partner nodes the server doesn't expose. Don't claim free. runtime = "unknown"; usesApiNodes = null; } else { // Either everything classified as local, OR this is a bundled pack (declared // local/free) whose only unclassifiable nodes are its own uninstalled custom // nodes — never API nodes. Trust the pack's local guarantee (#464). runtime = "local"; usesApiNodes = false; } return { runtime, usesApiNodes, apiNodes, externalApiNodes, ...(externalProviders.length > 0 ? { externalProviders } : {}), classTypes, unknownNodes, subgraphCount: countSubgraphs(graph), }; } function describeInputs(specs, required) { if (!specs) return []; return Object.entries(specs).map(([name, spec]) => { // Spec shape: [type, config?] where type is a string (e.g. "STRING") or a // string[] of enum options (e.g. ["fast", "quality"]). const type = Array.isArray(spec) ? spec[0] : spec; const config = (Array.isArray(spec) ? spec[1] : undefined) ?? {}; return { name, type, required, config: config, }; }); } /** * Project an /object_info entry into the schema shape. No API-node guard — the * auto-added output sink (see OUTPUT_SINKS) is a plain CORE node and needs the * same input description to resolve its own widget defaults. */ function nodeSchemaFrom(classType, def) { return { class_type: classType, display_name: def.display_name || classType, category: def.category ?? "", description: def.description ?? "", is_api_node: isApiNode(def), is_output_node: def.output_node === true, inputs: [ ...describeInputs(def.input?.required, true), ...describeInputs(def.input?.optional, false), ], hidden_inputs: def.input?.hidden ? Object.keys(def.input.hidden) : [], output: Array.isArray(def.output) ? def.output : [], output_name: Array.isArray(def.output_name) ? def.output_name : [], }; } /** Resolve an API node's schema against an already-fetched /object_info. */ function apiNodeSchemaFrom(objectInfo, classType) { const def = objectInfo[classType]; if (!def) { throw new ValidationError(`Node "${classType}" was not found on the connected ComfyUI. ` + `Use list_api_nodes to see available API nodes.`); } if (!isApiNode(def)) { throw new ValidationError(`Node "${classType}" exists but is not an API/partner node ` + `(category "${def.category ?? ""}"). Use list_api_nodes to find API nodes.`); } return nodeSchemaFrom(classType, def); } /** Return the input schema for a given API node (from its /object_info entry). */ export async function getApiNodeSchema(classType, deps = defaultDeps) { return apiNodeSchemaFrom(await deps.getObjectInfo(), classType); } const SIMPLE_WIDGET_TYPES = new Set(["INT", "FLOAT", "STRING", "BOOLEAN", "COMBO"]); /** * If a node input is a v3 dynamic combo, return its option list (each option has * a `key` plus the nested `inputs` it reveals). Returns null otherwise. */ function dynamicComboOptions(descriptor) { const type = descriptor.type; const opts = descriptor.config.options; if (!Array.isArray(opts)) return null; const typeIsDyn = typeof type === "string" && type.toUpperCase().includes("DYNAMICCOMBO"); // Also detect by shape: options that carry nested `inputs` (not plain strings). const shapeIsDyn = opts.some((o) => o != null && typeof o === "object" && "inputs" in o); if (!typeIsDyn && !shapeIsDyn) return null; return opts; } /** * Classify a nested input spec (from a dynamic combo option) as a POSITIONAL * widget (one that carries a scalar value the caller supplies) and resolve its * default. Non-widget nested inputs (AUTOGROW lists, IMAGE/link types) are not * emitted unless the caller explicitly provides a value. */ function classifyNestedSpec(spec) { if (!Array.isArray(spec)) return { isWidget: false, dflt: undefined }; const [type, cfg] = spec; if (Array.isArray(type)) { // Inline combo: ["1K","2K",...] return { isWidget: true, dflt: cfg?.default ?? type[0] }; } const t = String(type).toUpperCase(); if (SIMPLE_WIDGET_TYPES.has(t)) { let dflt = cfg?.default; if (dflt === undefined && Array.isArray(cfg?.options) && cfg.options.length) { dflt = cfg.options[0]; } return { isWidget: true, dflt }; } return { isWidget: false, dflt: undefined }; } /** * Build the `inputs` map for an API-node prompt from caller-provided values, * serializing any v3 dynamic-combo input into its dotted `model.<nested>` form. * * The caller may supply nested values in any of three natural shapes — already * dotted (`"model.aspect_ratio"`), flat (`"aspect_ratio"`), or as a nested object * (`model: { key, aspect_ratio }`). Required nested widgets the caller omits are * filled from their schema default so the server doesn't 400. Hidden inputs * (server-injected auth) are dropped. Exported for unit testing. */ export function buildApiNodeInputs(schema, provided) { const inputs = {}; const consumed = new Set(); // provided keys absorbed by combo expansion // Pass 1 — expand dynamic combos into dotted keys. for (const desc of schema.inputs) { const options = dynamicComboOptions(desc); if (!options) continue; const name = desc.name; // The selected option key may arrive as a string or inside a nested object. let selected = provided[name]; let selObj; if (selected != null && typeof selected === "object" && !Array.isArray(selected)) { selObj = selected; selected = selObj.key ?? selObj.value; } const cfgDefault = desc.config.default; if (selected === undefined || selected === null) { selected = cfgDefault ?? options[0]?.key; } const option = options.find((o) => o.key === selected) ?? options[0]; if (!option) continue; consumed.add(name); inputs[name] = option.key ?? selected; const emitNested = (nName, nSpec, requiredFill) => { const dottedKey = `${name}.${nName}`; let val; if (dottedKey in provided) { val = provided[dottedKey]; consumed.add(dottedKey); } else if (selObj && nName in selObj) { val = selObj[nName]; } else if (nName in provided) { val = provided[nName]; consumed.add(nName); } if (val === undefined) { if (!requiredFill) return; // optional / non-widget — omit when absent const info = classifyNestedSpec(nSpec); if (!info.isWidget || info.dflt === undefined) return; val = info.dflt; } inputs[dottedKey] = val; }; for (const [nName, nSpec] of Object.entries(option.inputs?.required ?? {})) { const info = classifyNestedSpec(nSpec); emitNested(nName, nSpec, info.isWidget); } for (const [nName, nSpec] of Object.entries(option.inputs?.optional ?? {})) { emitNested(nName, nSpec, false); } } // Pass 2 — copy remaining provided inputs (skip consumed + hidden). for (const [key, value] of Object.entries(provided)) { if (consumed.has(key)) continue; if (schema.hidden_inputs.includes(key)) continue; if (key in inputs) continue; inputs[key] = value; } // Pass 3 — fill any required top-level WIDGET input the caller omitted with its // schema default, so /prompt validation doesn't reject a missing required combo // /scalar (link-type inputs like IMAGE have no default and are left absent). for (const desc of schema.inputs) { if (!desc.required) continue; if (desc.name in inputs) continue; if (dynamicComboOptions(desc)) continue; // already emitted above const cfg = desc.config; const type = desc.type; let dflt; if (Array.isArray(type)) { dflt = cfg.default ?? type[0]; } else if (SIMPLE_WIDGET_TYPES.has(String(type).toUpperCase())) { dflt = cfg.default ?? (Array.isArray(cfg.options) ? cfg.options[0] : undefined); } if (dflt !== undefined) inputs[desc.name] = dflt; } return { inputs, consumed }; } /** * Output type → candidate sinks. BOTH lists are in preference order. * * Types are searched first, media before text, so a node declaring * ("VIDEO", "STRING") saves the video rather than the response text. Within a * type, candidates run current → deprecated → preview-only, so we write to * output/ when we can and only fall back to a temp-dir preview when we can't. */ const OUTPUT_SINKS = [ { type: "IMAGE", sinks: [ { class_type: "SaveImage", link_input: "images", defaults: { filename_prefix: "ComfyUI" } }, ], }, { type: "VIDEO", sinks: [ { class_type: "SaveVideo", link_input: "video", defaults: { filename_prefix: "video/ComfyUI" } }, ], }, { type: "AUDIO", sinks: [ { class_type: "SaveAudioAdvanced", link_input: "audio", defaults: { filename_prefix: "audio/ComfyUI" } }, { class_type: "SaveAudio", link_input: "audio", defaults: { filename_prefix: "audio/ComfyUI" } }, { class_type: "PreviewAudio", link_input: "audio" }, ], }, { type: "SVG", sinks: [ { class_type: "SaveSVGNode", link_input: "svg", defaults: { filename_prefix: "svg/ComfyUI" } }, ], }, // SaveGLB's `mesh` is a MultiType accepting the whole File3D family, so one // sink serves every 3D API node (Hunyuan3D/Tripo/Rodin/Meshy…). These are the // io_type WIRE strings ("FILE_3D_GLB"), not the python class names. ...["FILE_3D_GLB", "FILE_3D_OBJ", "FILE_3D_FBX", "FILE_3D"].map((type) => ({ type, sinks: [ { class_type: "SaveGLB", link_input: "mesh", defaults: { filename_prefix: "3d/ComfyUI" } }, ], })), // Text last: it is the fallback for nodes that return only a response string, // and for media nodes whose media sink this server does not register. { type: "STRING", sinks: [ { class_type: "SaveText", link_input: "text", defaults: { filename_prefix: "ComfyUI" } }, ], }, ]; /** * Pick the output node to terminate `schema`'s graph with: the first preferred * output type the node declares that has a candidate sink this server registers. * Returns the sink, its /object_info entry, and the API-node output slot to wire. */ function pickOutputSink(schema, objectInfo) { const outputs = schema.output.map((o) => String(o).toUpperCase()); for (const { type, sinks } of OUTPUT_SINKS) { const slot = outputs.indexOf(type); if (slot < 0) continue; for (const sink of sinks) { const def = objectInfo[sink.class_type]; if (def) return { sink, def, slot }; } } return null; } /** * Build a minimal single-node workflow that runs a chosen API node with the * provided inputs and enqueue it. Returns the prompt_id (reuses the existing * enqueue path). The API node is typically an output node, so it can stand * alone as a complete graph. */ export async function generateWithApiNode(args, deps = defaultDeps) { // One fetch, two readers: the API node's own schema and the output-sink pick // both come out of this /object_info snapshot. const objectInfo = await deps.getObjectInfo(); const schema = apiNodeSchemaFrom(objectInfo, args.class_type); const notes = []; const provided = args.inputs ?? {}; // Build the prompt inputs, serializing any v3 dynamic-combo input into its // dotted `model.<nested>` form (the canvas does this; a flat form 400s). const { inputs, consumed } = buildApiNodeInputs(schema, provided); // Acceptable input names: visible schema names, plus the nested names a dynamic // combo reveals (in both flat and dotted form) — so we don't warn about them. const knownNames = new Set(schema.inputs.map((i) => i.name)); for (const desc of schema.inputs) { const options = dynamicComboOptions(desc); if (!options) continue; for (const opt of options) { for (const nName of Object.keys(opt.inputs?.required ?? {})) { knownNames.add(nName); knownNames.add(`${desc.name}.${nName}`); } for (const nName of Object.keys(opt.inputs?.optional ?? {})) { knownNames.add(nName); knownNames.add(`${desc.name}.${nName}`); } } } // Warn about inputs that aren't part of the node's visible schema (typos, // or hidden auth fields the server should fill itself). for (const key of Object.keys(provided)) { if (knownNames.has(key) || consumed.has(key)) continue; if (schema.hidden_inputs.includes(key)) { notes.push(`Ignoring "${key}": it is a hidden input filled by the ComfyUI server, not the client.`); } else { notes.push(`Unknown input "${key}" for ${args.class_type} (passing through anyway).`); } } // Check required inputs are present in the built prompt (after combo expansion // and default-filling), and flag any genuinely missing ones. const missingRequired = schema.inputs .filter((i) => i.required && !(i.name in inputs)) .map((i) => i.name); if (missingRequired.length > 0) { notes.push(`Missing required input(s): ${missingRequired.join(", ")}. ` + `The server may reject the job. Use list_api_nodes (action:"schema") for details.`); } const workflow = { "1": { class_type: args.class_type, inputs, _meta: { title: schema.display_name }, }, }; // Merge caller-supplied supporting nodes (e.g. LoadImage → IMAGE link input). if (args.extra_nodes) { for (const [id, node] of Object.entries(args.extra_nodes)) { if (id === "1" || id === "2") { throw new ValidationError(`extra_nodes id "${id}" is reserved (the API node is "1"; "2" may be used for an auto-added output node). Use a different id.`); } workflow[id] = node; } } // ComfyUI only executes graphs that reach a terminal OUTPUT_NODE; a bare // non-output API node fails validation with "prompt_no_outputs". If the API // node isn't itself an output node, terminate it with a sink that matches the // type it actually returns (#2686 — this was IMAGE-only). if (!schema.is_output_node) { const picked = pickOutputSink(schema, objectInfo); if (picked) { const { sink, def, slot } = picked; // Resolve the sink's OWN widget values from its /object_info entry rather // than hardcoding them: SaveVideo and SaveAudioAdvanced both take a // REQUIRED v3 dynamic combo (`format`), which the server rebuilds from the // dotted `format` + `format.<nested>` keys buildApiNodeInputs emits. // Sending only the link would 400 with required_input_missing instead. const { inputs: sinkInputs } = buildApiNodeInputs(nodeSchemaFrom(sink.class_type, def), { [sink.link_input]: ["1", slot], ...(sink.defaults ?? {}), }); workflow["2"] = { class_type: sink.class_type, inputs: sinkInputs, _meta: { title: def.display_name || sink.class_type }, }; notes.push(`Added a ${sink.class_type} output node wired to the ${schema.output[slot]} output — ComfyUI requires a terminal output node for the prompt to execute.`); } else { const declared = schema.output.length > 0 ? schema.output.join(", ") : "nothing"; notes.push(`"${args.class_type}" is not an output node and returns ${declared}, which this ` + "ComfyUI has no registered output node for, so none was auto-added; the prompt " + "may fail with 'prompt_no_outputs'. Pass a terminal output node via extra_nodes " + "if you need one."); } } // comfy.org API-node credentials travel in /prompt extra_data (the server // injects them into the node's hidden inputs). Supply the configured key so // API nodes work even when the ComfyUI server isn't logged in itself. const extraData = {}; if (config.comfyApiKey) { extraData.api_key_comfy_org = config.comfyApiKey; notes.push("Attached COMFY_API_KEY to the request (extra_data.api_key_comfy_org)."); } else { notes.push("No COMFY_API_KEY configured — relying on the ComfyUI server's own logged-in " + "comfy.org session for API-node auth. Set COMFY_API_KEY if the server isn't logged in."); } const result = await deps.enqueue(workflow, { disable_random_seed: args.disable_random_seed, extra_data: Object.keys(extraData).length > 0 ? extraData : undefined, }); return { prompt_id: result.prompt_id, queue_remaining: result.queue_remaining, rejectedOutputs: result.rejectedOutputs, workflow, notes, }; } //# sourceMappingURL=api-nodes.js.map