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
JavaScript
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