@openclaw/comfy-provider 0.0.0 → 2026.8.1-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,26 @@
1
1
  # @openclaw/comfy-provider
2
2
 
3
- Reserved placeholder for the official OpenClaw plugin package. Use a published release version instead.
3
+ Official ComfyUI image, video, and music generation provider plugin for
4
+ OpenClaw.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ openclaw plugins install @openclaw/comfy-provider
10
+ openclaw gateway restart
11
+ ```
12
+
13
+ ## Configure
14
+
15
+ Local ComfyUI workflows do not require credentials. Comfy Cloud workflows use
16
+ `COMFY_API_KEY` or `COMFY_CLOUD_API_KEY`.
17
+
18
+ Full workflow, model, and provider configuration:
19
+
20
+ - https://docs.openclaw.ai/providers/comfy
21
+
22
+ ## Package
23
+
24
+ - Plugin id: `comfy`
25
+ - Package: `@openclaw/comfy-provider`
26
+ - Minimum OpenClaw host: `2026.7.2`
@@ -0,0 +1,63 @@
1
+ import { DEFAULT_COMFY_MODEL, isComfyCapabilityConfigured, runComfyWorkflow } from "./workflow-runtime.js";
2
+ //#region extensions/comfy/image-generation-provider.ts
3
+ function buildComfyImageGenerationProvider() {
4
+ return {
5
+ id: "comfy",
6
+ label: "ComfyUI",
7
+ defaultModel: DEFAULT_COMFY_MODEL,
8
+ models: [DEFAULT_COMFY_MODEL],
9
+ isConfigured: ({ cfg, agentDir }) => isComfyCapabilityConfigured({
10
+ cfg,
11
+ agentDir,
12
+ capability: "image"
13
+ }),
14
+ capabilities: {
15
+ generate: {
16
+ maxCount: 1,
17
+ supportsSize: false,
18
+ supportsAspectRatio: false,
19
+ supportsResolution: false
20
+ },
21
+ edit: {
22
+ enabled: true,
23
+ maxCount: 1,
24
+ maxInputImages: 1,
25
+ supportsSize: false,
26
+ supportsAspectRatio: false,
27
+ supportsResolution: false
28
+ }
29
+ },
30
+ async generateImage(req) {
31
+ if ((req.inputImages?.length ?? 0) > 1) throw new Error("Comfy image generation currently supports at most one reference image");
32
+ const result = await runComfyWorkflow({
33
+ cfg: req.cfg,
34
+ agentDir: req.agentDir,
35
+ authStore: req.authStore,
36
+ prompt: req.prompt,
37
+ model: req.model,
38
+ timeoutMs: req.timeoutMs,
39
+ capability: "image",
40
+ outputKinds: ["images"],
41
+ inputImage: req.inputImages?.[0]
42
+ });
43
+ return {
44
+ images: result.assets.map((asset) => ({
45
+ buffer: asset.buffer,
46
+ mimeType: asset.mimeType,
47
+ fileName: asset.fileName,
48
+ metadata: {
49
+ nodeId: asset.nodeId,
50
+ promptId: result.promptId
51
+ }
52
+ })),
53
+ model: result.model,
54
+ metadata: {
55
+ promptId: result.promptId,
56
+ outputNodeIds: result.outputNodeIds
57
+ }
58
+ };
59
+ }
60
+ };
61
+ }
62
+ //#endregion
63
+ export { buildComfyImageGenerationProvider };
package/dist/index.js ADDED
@@ -0,0 +1,44 @@
1
+ import { buildComfyImageGenerationProvider } from "./image-generation-provider.js";
2
+ import { buildComfyMusicGenerationProvider } from "./music-generation-provider.js";
3
+ import { buildComfyVideoGenerationProvider } from "./video-generation-provider.js";
4
+ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
5
+ import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
6
+ //#region extensions/comfy/index.ts
7
+ const PROVIDER_ID = "comfy";
8
+ var comfy_default = definePluginEntry({
9
+ id: PROVIDER_ID,
10
+ name: "ComfyUI Provider",
11
+ description: "Bundled ComfyUI workflow media generation provider",
12
+ register(api) {
13
+ api.registerProvider({
14
+ id: PROVIDER_ID,
15
+ label: "ComfyUI",
16
+ docsPath: "/providers/comfy",
17
+ envVars: ["COMFY_API_KEY", "COMFY_CLOUD_API_KEY"],
18
+ auth: [createProviderApiKeyAuthMethod({
19
+ providerId: PROVIDER_ID,
20
+ methodId: "cloud-api-key",
21
+ label: "Comfy Cloud API key",
22
+ hint: "API key for Comfy Cloud workflow runs",
23
+ optionKey: "comfyApiKey",
24
+ flagName: "--comfy-api-key",
25
+ envVar: "COMFY_API_KEY",
26
+ promptMessage: "Enter Comfy Cloud API key",
27
+ wizard: {
28
+ choiceId: "comfy-cloud-api-key",
29
+ choiceLabel: "Comfy Cloud API key",
30
+ choiceHint: "Required for cloud workflows",
31
+ groupId: "comfy",
32
+ groupLabel: "ComfyUI",
33
+ groupHint: "Local or cloud workflows",
34
+ onboardingScopes: ["image-generation"]
35
+ }
36
+ })]
37
+ });
38
+ api.registerImageGenerationProvider(buildComfyImageGenerationProvider());
39
+ api.registerMusicGenerationProvider(buildComfyMusicGenerationProvider());
40
+ api.registerVideoGenerationProvider(buildComfyVideoGenerationProvider());
41
+ }
42
+ });
43
+ //#endregion
44
+ export { comfy_default as default };
@@ -0,0 +1,63 @@
1
+ import { DEFAULT_COMFY_MODEL, isComfyCapabilityConfigured, runComfyWorkflow } from "./workflow-runtime.js";
2
+ //#region extensions/comfy/music-generation-provider.ts
3
+ const COMFY_MAX_INPUT_IMAGES = 1;
4
+ function toGeneratedTrack(asset) {
5
+ return {
6
+ buffer: asset.buffer,
7
+ mimeType: asset.mimeType,
8
+ fileName: asset.fileName
9
+ };
10
+ }
11
+ function resolveInputImage(inputImage) {
12
+ if (!inputImage) return;
13
+ if (!inputImage.buffer) throw new Error("Comfy music generation requires loaded reference image bytes.");
14
+ return {
15
+ buffer: inputImage.buffer,
16
+ mimeType: inputImage.mimeType ?? "image/png",
17
+ fileName: inputImage.fileName
18
+ };
19
+ }
20
+ function buildComfyMusicGenerationProvider() {
21
+ return {
22
+ id: "comfy",
23
+ label: "ComfyUI",
24
+ defaultModel: DEFAULT_COMFY_MODEL,
25
+ models: [DEFAULT_COMFY_MODEL],
26
+ isConfigured: ({ cfg, agentDir }) => isComfyCapabilityConfigured({
27
+ cfg,
28
+ agentDir,
29
+ capability: "music"
30
+ }),
31
+ capabilities: {
32
+ generate: {},
33
+ edit: {
34
+ enabled: true,
35
+ maxInputImages: COMFY_MAX_INPUT_IMAGES
36
+ }
37
+ },
38
+ async generateMusic(req) {
39
+ if ((req.inputImages?.length ?? 0) > COMFY_MAX_INPUT_IMAGES) throw new Error(`Comfy music generation supports at most ${COMFY_MAX_INPUT_IMAGES} reference image.`);
40
+ const result = await runComfyWorkflow({
41
+ cfg: req.cfg,
42
+ agentDir: req.agentDir,
43
+ authStore: req.authStore,
44
+ prompt: req.prompt,
45
+ model: req.model,
46
+ capability: "music",
47
+ outputKinds: ["audio"],
48
+ inputImage: resolveInputImage(req.inputImages?.[0])
49
+ });
50
+ return {
51
+ tracks: result.assets.map(toGeneratedTrack),
52
+ model: result.model,
53
+ metadata: {
54
+ promptId: result.promptId,
55
+ outputNodeIds: result.outputNodeIds,
56
+ inputImageCount: req.inputImages?.length ?? 0
57
+ }
58
+ };
59
+ }
60
+ };
61
+ }
62
+ //#endregion
63
+ export { buildComfyMusicGenerationProvider };
@@ -0,0 +1,82 @@
1
+ import { DEFAULT_COMFY_MODEL, isComfyCapabilityConfigured, runComfyWorkflow } from "./workflow-runtime.js";
2
+ //#region extensions/comfy/video-generation-provider.ts
3
+ function toComfyInputImage(inputImage) {
4
+ if (!inputImage) return;
5
+ if (!inputImage.buffer || !inputImage.mimeType) throw new Error("Comfy video generation requires a local reference image file");
6
+ return {
7
+ buffer: inputImage.buffer,
8
+ mimeType: inputImage.mimeType,
9
+ fileName: inputImage.fileName
10
+ };
11
+ }
12
+ function buildComfyVideoGenerationProvider() {
13
+ return {
14
+ id: "comfy",
15
+ label: "ComfyUI",
16
+ defaultModel: DEFAULT_COMFY_MODEL,
17
+ models: [DEFAULT_COMFY_MODEL],
18
+ isConfigured: ({ cfg, agentDir }) => isComfyCapabilityConfigured({
19
+ cfg,
20
+ agentDir,
21
+ capability: "video"
22
+ }),
23
+ capabilities: {
24
+ generate: {
25
+ maxVideos: 1,
26
+ supportsSize: false,
27
+ supportsAspectRatio: false,
28
+ supportsResolution: false,
29
+ supportsAudio: false,
30
+ supportsWatermark: false
31
+ },
32
+ imageToVideo: {
33
+ enabled: true,
34
+ maxVideos: 1,
35
+ maxInputImages: 1,
36
+ supportsSize: false,
37
+ supportsAspectRatio: false,
38
+ supportsResolution: false,
39
+ supportsAudio: false,
40
+ supportsWatermark: false
41
+ },
42
+ videoToVideo: { enabled: false }
43
+ },
44
+ async generateVideo(req) {
45
+ if ((req.inputImages?.length ?? 0) > 1) throw new Error("Comfy video generation currently supports at most one reference image");
46
+ if ((req.inputVideos?.length ?? 0) > 0) throw new Error("Comfy video generation does not support input videos");
47
+ const result = await runComfyWorkflow({
48
+ cfg: req.cfg,
49
+ agentDir: req.agentDir,
50
+ authStore: req.authStore,
51
+ prompt: req.prompt,
52
+ model: req.model,
53
+ timeoutMs: req.timeoutMs,
54
+ capability: "video",
55
+ outputKinds: [
56
+ "images",
57
+ "gifs",
58
+ "videos"
59
+ ],
60
+ inputImage: toComfyInputImage(req.inputImages?.[0])
61
+ });
62
+ return {
63
+ videos: result.assets.map((asset) => ({
64
+ buffer: asset.buffer,
65
+ mimeType: asset.mimeType,
66
+ fileName: asset.fileName,
67
+ metadata: {
68
+ nodeId: asset.nodeId,
69
+ promptId: result.promptId
70
+ }
71
+ })),
72
+ model: result.model,
73
+ metadata: {
74
+ promptId: result.promptId,
75
+ outputNodeIds: result.outputNodeIds
76
+ }
77
+ };
78
+ }
79
+ };
80
+ }
81
+ //#endregion
82
+ export { buildComfyVideoGenerationProvider };
@@ -0,0 +1,480 @@
1
+ import fs from "node:fs/promises";
2
+ import { canResolveEnvSecretRefInReadOnlyPath } from "openclaw/plugin-sdk/extension-shared";
3
+ import { resolveGeneratedMediaMaxBytes } from "openclaw/plugin-sdk/media-generation-runtime";
4
+ import { extensionForMime } from "openclaw/plugin-sdk/media-mime";
5
+ import { resolvePositiveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
6
+ import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
7
+ import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
8
+ import { assertOkOrThrowHttpError, normalizeBaseUrl, readProviderJsonResponse, resolveProviderHttpRequestConfig } from "openclaw/plugin-sdk/provider-http";
9
+ import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
10
+ import { normalizeSecretInputString, resolveSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime";
11
+ import { fetchWithSsrFGuard, isPrivateOrLoopbackHost, mergeSsrFPolicies, ssrfPolicyFromHttpBaseUrlAllowedOrigin } from "openclaw/plugin-sdk/ssrf-runtime";
12
+ import { asBoolean, isRecord, normalizeOptionalLowercaseString, normalizeOptionalString, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
13
+ import { resolveUserPath } from "openclaw/plugin-sdk/text-utility-runtime";
14
+ //#region extensions/comfy/workflow-runtime.ts
15
+ const DEFAULT_COMFY_LOCAL_BASE_URL = "http://127.0.0.1:8188";
16
+ const DEFAULT_COMFY_CLOUD_BASE_URL = "https://cloud.comfy.org";
17
+ const DEFAULT_PROMPT_INPUT_NAME = "text";
18
+ const DEFAULT_INPUT_IMAGE_INPUT_NAME = "image";
19
+ const DEFAULT_POLL_INTERVAL_MS = 1500;
20
+ const DEFAULT_TIMEOUT_MS = 5 * 6e4;
21
+ const DEFAULT_COMFY_MODEL = "workflow";
22
+ function readConfigInteger(config, key) {
23
+ const value = config[key];
24
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : void 0;
25
+ }
26
+ function getComfyConfig(cfg) {
27
+ const pluginConfig = cfg?.plugins?.entries?.comfy?.config;
28
+ if (isRecord(pluginConfig)) return pluginConfig;
29
+ const legacyConfig = cfg?.models?.providers?.comfy;
30
+ return isRecord(legacyConfig) ? legacyConfig : {};
31
+ }
32
+ function stripNestedCapabilityConfig(config) {
33
+ const next = { ...config };
34
+ delete next.image;
35
+ delete next.video;
36
+ delete next.music;
37
+ return next;
38
+ }
39
+ function getComfyCapabilityConfig(config, capability) {
40
+ const shared = stripNestedCapabilityConfig(config);
41
+ const nested = config[capability];
42
+ if (!isRecord(nested)) return shared;
43
+ return {
44
+ ...shared,
45
+ ...nested
46
+ };
47
+ }
48
+ function resolveComfyMode(config) {
49
+ return normalizeOptionalString(config.mode) === "cloud" ? "cloud" : "local";
50
+ }
51
+ function resolveComfyApiKey(config, cfg) {
52
+ const resolved = resolveSecretInputString({
53
+ value: config.apiKey,
54
+ path: "plugins.entries.comfy.config.apiKey",
55
+ defaults: cfg?.secrets?.defaults,
56
+ mode: "inspect"
57
+ });
58
+ if (resolved.status === "available") {
59
+ const apiKey = normalizeSecretInputString(resolved.value);
60
+ return apiKey ? {
61
+ status: "available",
62
+ apiKey,
63
+ source: "plugins.entries.comfy.config.apiKey"
64
+ } : { status: "missing" };
65
+ }
66
+ if (resolved.status === "configured_unavailable") {
67
+ if (resolved.ref.source !== "env") return { status: "configured_unavailable" };
68
+ const envVarName = resolved.ref.id.trim();
69
+ if (!canResolveEnvSecretRefInReadOnlyPath({
70
+ cfg,
71
+ provider: resolved.ref.provider,
72
+ id: envVarName
73
+ })) return { status: "configured_unavailable" };
74
+ const apiKey = normalizeSecretInputString(process.env[envVarName]);
75
+ return apiKey ? {
76
+ status: "available",
77
+ apiKey,
78
+ source: `plugins.entries.comfy.config.apiKey (${envVarName})`
79
+ } : { status: "configured_unavailable" };
80
+ }
81
+ return { status: "missing" };
82
+ }
83
+ function getRequiredConfigString(config, key) {
84
+ const value = normalizeOptionalString(config[key]);
85
+ if (!value) throw new Error(`plugins.entries.comfy.config.${key} is required`);
86
+ return value;
87
+ }
88
+ function resolveComfyWorkflowSource(config) {
89
+ const workflow = config.workflow;
90
+ if (isRecord(workflow)) return { workflow: structuredClone(workflow) };
91
+ return { workflowPath: normalizeOptionalString(config.workflowPath) };
92
+ }
93
+ async function loadComfyWorkflow(config) {
94
+ const source = resolveComfyWorkflowSource(config);
95
+ if (source.workflow) return source.workflow;
96
+ if (!source.workflowPath) throw new Error("plugins.entries.comfy.config.<capability>.workflow or workflowPath is required");
97
+ const resolvedPath = resolveUserPath(source.workflowPath);
98
+ const raw = await fs.readFile(resolvedPath, "utf8");
99
+ const parsed = JSON.parse(raw);
100
+ if (!isRecord(parsed)) throw new Error(`Comfy workflow at ${resolvedPath} must be a JSON object`);
101
+ return parsed;
102
+ }
103
+ function setWorkflowInput(params) {
104
+ const node = params.workflow[params.nodeId];
105
+ if (!isRecord(node)) throw new Error(`Comfy workflow missing node "${params.nodeId}"`);
106
+ const inputs = node.inputs;
107
+ if (!isRecord(inputs)) throw new Error(`Comfy workflow node "${params.nodeId}" is missing an inputs object`);
108
+ inputs[params.inputName] = params.value;
109
+ }
110
+ function resolveComfyNetworkPolicy(params) {
111
+ let parsed;
112
+ try {
113
+ parsed = new URL(params.baseUrl);
114
+ } catch {
115
+ return {};
116
+ }
117
+ const hostname = normalizeOptionalLowercaseString(parsed.hostname) ?? "";
118
+ if (!hostname) return {};
119
+ const localHostnamePolicy = params.mode === "local" ? { hostnameAllowlist: [hostname] } : void 0;
120
+ const hostnameOnlyPolicy = localHostnamePolicy ? { apiPolicy: localHostnamePolicy } : {};
121
+ if (!params.allowPrivateNetwork) return hostnameOnlyPolicy;
122
+ if (!params.explicitAllowPrivateNetwork && params.mode !== "local") return {};
123
+ if (!params.explicitAllowPrivateNetwork && params.mode === "local" && !isPrivateOrLoopbackHost(hostname) && !isSingleLabelServiceHostname(hostname)) return hostnameOnlyPolicy;
124
+ const originPolicy = ssrfPolicyFromHttpBaseUrlAllowedOrigin(params.baseUrl);
125
+ if (!originPolicy) return hostnameOnlyPolicy;
126
+ return { apiPolicy: params.mode === "local" ? mergeSsrFPolicies(originPolicy, localHostnamePolicy) : originPolicy };
127
+ }
128
+ function isSingleLabelServiceHostname(hostname) {
129
+ return /^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/u.test(hostname);
130
+ }
131
+ async function readJsonResponse(params) {
132
+ const { response, release } = await fetchWithSsrFGuard({
133
+ url: params.url,
134
+ init: params.init,
135
+ timeoutMs: params.timeoutMs,
136
+ policy: params.policy,
137
+ dispatcherPolicy: params.dispatcherPolicy,
138
+ auditContext: params.auditContext
139
+ });
140
+ try {
141
+ await assertOkOrThrowHttpError(response, params.errorPrefix);
142
+ return await readProviderJsonResponse(response, params.errorPrefix);
143
+ } finally {
144
+ await release();
145
+ }
146
+ }
147
+ function resolveFileExtension(params) {
148
+ const extension = extensionForMime(params.mimeType);
149
+ if (extension) return extension.slice(1);
150
+ const fileName = params.fileName?.trim();
151
+ if (!fileName) return "bin";
152
+ const dotIndex = fileName.lastIndexOf(".");
153
+ if (dotIndex < 0 || dotIndex === fileName.length - 1) return "bin";
154
+ return fileName.slice(dotIndex + 1);
155
+ }
156
+ function toBlobBytes(buffer) {
157
+ const arrayBuffer = new ArrayBuffer(buffer.byteLength);
158
+ new Uint8Array(arrayBuffer).set(buffer);
159
+ return arrayBuffer;
160
+ }
161
+ async function uploadInputImage(params) {
162
+ const form = new FormData();
163
+ form.set("image", new Blob([toBlobBytes(params.image.buffer)], { type: params.image.mimeType }), normalizeOptionalString(params.image.fileName) || `input.${resolveFileExtension({ mimeType: params.image.mimeType })}`);
164
+ form.set("type", "input");
165
+ form.set("overwrite", "true");
166
+ const headers = new Headers(params.headers);
167
+ headers.delete("Content-Type");
168
+ const payload = await readJsonResponse({
169
+ url: `${params.baseUrl}${params.mode === "cloud" ? "/api/upload/image" : "/upload/image"}`,
170
+ init: {
171
+ method: "POST",
172
+ headers,
173
+ body: form
174
+ },
175
+ timeoutMs: params.timeoutMs,
176
+ policy: params.policy,
177
+ dispatcherPolicy: params.dispatcherPolicy,
178
+ auditContext: `comfy-${params.capability}-upload`,
179
+ errorPrefix: "Comfy image upload failed"
180
+ });
181
+ const uploadedName = normalizeOptionalString(payload.filename) || normalizeOptionalString(payload.name);
182
+ if (!uploadedName) throw new Error("Comfy image upload response missing filename");
183
+ return uploadedName;
184
+ }
185
+ function extractHistoryEntry(history, promptId) {
186
+ if (!isRecord(history)) return null;
187
+ const directOutputs = history.outputs;
188
+ if (isRecord(directOutputs)) return history;
189
+ const nested = history[promptId];
190
+ if (isRecord(nested)) return nested;
191
+ return null;
192
+ }
193
+ async function waitForLocalHistory(params) {
194
+ const deadline = Date.now() + params.timeoutMs;
195
+ for (;;) {
196
+ const requestTimeoutMs = resolveComfyRemainingMs(deadline, params.timeoutMs);
197
+ const entry = extractHistoryEntry(await readJsonResponse({
198
+ url: `${params.baseUrl}/history/${params.promptId}`,
199
+ init: {
200
+ method: "GET",
201
+ headers: params.headers
202
+ },
203
+ timeoutMs: requestTimeoutMs,
204
+ policy: params.policy,
205
+ dispatcherPolicy: params.dispatcherPolicy,
206
+ auditContext: "comfy-history",
207
+ errorPrefix: "Comfy history lookup failed"
208
+ }), params.promptId);
209
+ if (entry?.outputs && Object.keys(entry.outputs).length > 0) return entry;
210
+ const pollDelayMs = resolveComfyRemainingMs(deadline, params.timeoutMs, params.pollIntervalMs);
211
+ await new Promise((resolve) => {
212
+ setTimeout(resolve, pollDelayMs);
213
+ });
214
+ }
215
+ }
216
+ async function waitForCloudCompletion(params) {
217
+ const deadline = Date.now() + params.timeoutMs;
218
+ for (;;) {
219
+ const requestTimeoutMs = resolveComfyRemainingMs(deadline, params.timeoutMs);
220
+ const status = await readJsonResponse({
221
+ url: `${params.baseUrl}/api/job/${params.promptId}/status`,
222
+ init: {
223
+ method: "GET",
224
+ headers: params.headers
225
+ },
226
+ timeoutMs: requestTimeoutMs,
227
+ policy: params.policy,
228
+ dispatcherPolicy: params.dispatcherPolicy,
229
+ auditContext: "comfy-status",
230
+ errorPrefix: "Comfy status lookup failed"
231
+ });
232
+ if (status.status === "completed") return;
233
+ if (status.status === "failed" || status.status === "cancelled") throw new Error(`Comfy workflow ${status.status}: ${status.error ?? status.message ?? params.promptId}`);
234
+ const pollDelayMs = resolveComfyRemainingMs(deadline, params.timeoutMs, params.pollIntervalMs);
235
+ await new Promise((resolve) => {
236
+ setTimeout(resolve, pollDelayMs);
237
+ });
238
+ }
239
+ }
240
+ function resolveComfyRemainingMs(deadline, timeoutMs, defaultTimeoutMs = timeoutMs) {
241
+ const defaultMs = resolvePositiveTimerTimeoutMs(defaultTimeoutMs, 1);
242
+ const remainingMs = deadline - Date.now();
243
+ if (remainingMs <= 0) throw new Error(`Comfy workflow did not finish within ${Math.ceil(timeoutMs / 1e3)}s`);
244
+ return Math.max(1, Math.min(defaultMs, remainingMs));
245
+ }
246
+ function collectOutputFiles(params) {
247
+ const outputs = params.history.outputs;
248
+ if (!outputs) return [];
249
+ const nodeIds = params.outputNodeId ? [params.outputNodeId] : Object.keys(outputs);
250
+ const files = [];
251
+ for (const nodeId of nodeIds) {
252
+ const entry = outputs[nodeId];
253
+ if (!entry) continue;
254
+ for (const kind of params.outputKinds) {
255
+ const bucket = entry[kind];
256
+ if (!Array.isArray(bucket)) continue;
257
+ for (const file of bucket) {
258
+ if (params.capability === "video" && kind === "images") {
259
+ const fileName = normalizeOptionalString(file.filename) || normalizeOptionalString(file.name);
260
+ if (!fileName || !/\.(?:mp4|webm)$/i.test(fileName)) continue;
261
+ }
262
+ files.push({
263
+ nodeId,
264
+ file
265
+ });
266
+ }
267
+ }
268
+ }
269
+ return files;
270
+ }
271
+ async function downloadOutputFile(params) {
272
+ const fileName = normalizeOptionalString(params.file.filename) || normalizeOptionalString(params.file.name);
273
+ if (!fileName) throw new Error("Comfy output entry missing filename");
274
+ const query = new URLSearchParams({
275
+ filename: fileName,
276
+ subfolder: normalizeOptionalString(params.file.subfolder) ?? "",
277
+ type: normalizeOptionalString(params.file.type) ?? "output"
278
+ });
279
+ const viewPath = params.mode === "cloud" ? "/api/view" : "/view";
280
+ const auditContext = `comfy-${params.capability}-download`;
281
+ const firstResponse = await fetchWithSsrFGuard({
282
+ url: `${params.baseUrl}${viewPath}?${query.toString()}`,
283
+ init: {
284
+ method: "GET",
285
+ headers: params.headers
286
+ },
287
+ timeoutMs: params.timeoutMs,
288
+ policy: params.policy,
289
+ dispatcherPolicy: params.dispatcherPolicy,
290
+ auditContext
291
+ });
292
+ try {
293
+ await assertOkOrThrowHttpError(firstResponse.response, "Comfy output download failed");
294
+ const mimeType = normalizeOptionalString(firstResponse.response.headers.get("content-type")) || "application/octet-stream";
295
+ return {
296
+ buffer: await readResponseWithLimit(firstResponse.response, params.maxBytes, {
297
+ chunkTimeoutMs: params.timeoutMs,
298
+ onOverflow: ({ maxBytes }) => /* @__PURE__ */ new Error(`Comfy ${params.capability} output download exceeds ${maxBytes} bytes`),
299
+ onIdleTimeout: ({ chunkTimeoutMs }) => /* @__PURE__ */ new Error(`Comfy ${params.capability} output download stalled after ${chunkTimeoutMs}ms`)
300
+ }),
301
+ mimeType
302
+ };
303
+ } finally {
304
+ await firstResponse.release();
305
+ }
306
+ }
307
+ function isComfyCapabilityConfigured(params) {
308
+ const capabilityConfig = getComfyCapabilityConfig(getComfyConfig(params.cfg), params.capability);
309
+ const hasWorkflow = Boolean(resolveComfyWorkflowSource(capabilityConfig).workflow || normalizeOptionalString(capabilityConfig.workflowPath));
310
+ const hasPromptNode = Boolean(normalizeOptionalString(capabilityConfig.promptNodeId));
311
+ if (!hasWorkflow || !hasPromptNode) return false;
312
+ if (resolveComfyMode(capabilityConfig) === "local") return true;
313
+ const configuredApiKey = resolveComfyApiKey(capabilityConfig, params.cfg);
314
+ if (configuredApiKey.status === "available") return true;
315
+ if (configuredApiKey.status === "configured_unavailable") return false;
316
+ return isProviderApiKeyConfigured({
317
+ provider: "comfy",
318
+ cfg: params.cfg,
319
+ agentDir: params.agentDir
320
+ });
321
+ }
322
+ async function runComfyWorkflow(params) {
323
+ const capabilityConfig = getComfyCapabilityConfig(getComfyConfig(params.cfg), params.capability);
324
+ const mode = resolveComfyMode(capabilityConfig);
325
+ const workflow = await loadComfyWorkflow(capabilityConfig);
326
+ const promptNodeId = getRequiredConfigString(capabilityConfig, "promptNodeId");
327
+ const promptInputName = normalizeOptionalString(capabilityConfig.promptInputName) ?? DEFAULT_PROMPT_INPUT_NAME;
328
+ const inputImageNodeId = normalizeOptionalString(capabilityConfig.inputImageNodeId);
329
+ const inputImageInputName = normalizeOptionalString(capabilityConfig.inputImageInputName) ?? DEFAULT_INPUT_IMAGE_INPUT_NAME;
330
+ const outputNodeId = normalizeOptionalString(capabilityConfig.outputNodeId);
331
+ const pollIntervalMs = resolvePositiveTimerTimeoutMs(readConfigInteger(capabilityConfig, "pollIntervalMs"), DEFAULT_POLL_INTERVAL_MS);
332
+ const timeoutMs = resolvePositiveTimerTimeoutMs(readConfigInteger(capabilityConfig, "timeoutMs") ?? params.timeoutMs, DEFAULT_TIMEOUT_MS);
333
+ const providerModel = normalizeOptionalString(params.model) || "workflow";
334
+ setWorkflowInput({
335
+ workflow,
336
+ nodeId: promptNodeId,
337
+ inputName: promptInputName,
338
+ value: params.prompt
339
+ });
340
+ const pluginApiKey = resolveComfyApiKey(capabilityConfig, params.cfg);
341
+ const resolvedAuth = mode === "cloud" ? pluginApiKey.status === "available" ? {
342
+ apiKey: pluginApiKey.apiKey,
343
+ source: pluginApiKey.source,
344
+ mode: "api-key"
345
+ } : pluginApiKey.status === "configured_unavailable" ? null : await resolveApiKeyForProvider({
346
+ provider: "comfy",
347
+ cfg: params.cfg,
348
+ agentDir: params.agentDir,
349
+ store: params.authStore
350
+ }) : null;
351
+ if (mode === "cloud" && !resolvedAuth?.apiKey) throw new Error("Comfy Cloud API key missing");
352
+ const explicitAllowPrivateNetwork = asBoolean(capabilityConfig.allowPrivateNetwork) === true;
353
+ const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } = resolveProviderHttpRequestConfig({
354
+ baseUrl: normalizeOptionalString(capabilityConfig.baseUrl),
355
+ defaultBaseUrl: mode === "cloud" ? DEFAULT_COMFY_CLOUD_BASE_URL : DEFAULT_COMFY_LOCAL_BASE_URL,
356
+ allowPrivateNetwork: mode === "local" || explicitAllowPrivateNetwork,
357
+ defaultHeaders: mode === "cloud" ? {
358
+ "X-API-Key": resolvedAuth?.apiKey ?? "",
359
+ "Content-Type": "application/json"
360
+ } : { "Content-Type": "application/json" },
361
+ provider: "comfy",
362
+ capability: params.capability === "music" ? "audio" : params.capability,
363
+ transport: "http"
364
+ });
365
+ const normalizedBaseUrl = normalizeBaseUrl(baseUrl) || (mode === "cloud" ? DEFAULT_COMFY_CLOUD_BASE_URL : DEFAULT_COMFY_LOCAL_BASE_URL);
366
+ const networkPolicy = resolveComfyNetworkPolicy({
367
+ baseUrl: normalizedBaseUrl,
368
+ allowPrivateNetwork,
369
+ explicitAllowPrivateNetwork,
370
+ mode
371
+ });
372
+ if (params.inputImage) {
373
+ if (!inputImageNodeId) throw new Error("Comfy edit requests require plugins.entries.comfy.config.<capability>.inputImageNodeId to be configured");
374
+ setWorkflowInput({
375
+ workflow,
376
+ nodeId: inputImageNodeId,
377
+ inputName: inputImageInputName,
378
+ value: await uploadInputImage({
379
+ baseUrl: normalizedBaseUrl,
380
+ headers: new Headers(headers),
381
+ timeoutMs,
382
+ policy: networkPolicy.apiPolicy,
383
+ dispatcherPolicy,
384
+ image: params.inputImage,
385
+ mode,
386
+ capability: params.capability
387
+ })
388
+ });
389
+ }
390
+ const submitPayload = {
391
+ prompt: workflow,
392
+ ...mode === "cloud" && resolvedAuth?.apiKey ? { extra_data: { api_key_comfy_org: resolvedAuth.apiKey } } : {}
393
+ };
394
+ const promptId = normalizeOptionalString((await readJsonResponse({
395
+ url: `${normalizedBaseUrl}${mode === "cloud" ? "/api/prompt" : "/prompt"}`,
396
+ init: {
397
+ method: "POST",
398
+ headers,
399
+ body: JSON.stringify(submitPayload)
400
+ },
401
+ timeoutMs,
402
+ policy: networkPolicy.apiPolicy,
403
+ dispatcherPolicy,
404
+ auditContext: `comfy-${params.capability}-generate`,
405
+ errorPrefix: "Comfy workflow submit failed"
406
+ })).prompt_id);
407
+ if (!promptId) throw new Error("Comfy workflow submit response missing prompt_id");
408
+ const historyEntry = extractHistoryEntry(mode === "cloud" ? await (async () => {
409
+ await waitForCloudCompletion({
410
+ baseUrl: normalizedBaseUrl,
411
+ promptId,
412
+ headers: new Headers(headers),
413
+ timeoutMs,
414
+ pollIntervalMs,
415
+ policy: networkPolicy.apiPolicy,
416
+ dispatcherPolicy
417
+ });
418
+ return await readJsonResponse({
419
+ url: `${normalizedBaseUrl}/api/history_v2/${promptId}`,
420
+ init: {
421
+ method: "GET",
422
+ headers: new Headers(headers)
423
+ },
424
+ timeoutMs,
425
+ policy: networkPolicy.apiPolicy,
426
+ dispatcherPolicy,
427
+ auditContext: "comfy-history",
428
+ errorPrefix: "Comfy history lookup failed"
429
+ });
430
+ })() : await waitForLocalHistory({
431
+ baseUrl: normalizedBaseUrl,
432
+ promptId,
433
+ headers: new Headers(headers),
434
+ timeoutMs,
435
+ pollIntervalMs,
436
+ policy: networkPolicy.apiPolicy,
437
+ dispatcherPolicy
438
+ }), promptId);
439
+ if (!historyEntry) throw new Error(`Comfy history response missing outputs for prompt ${promptId}`);
440
+ const outputFiles = collectOutputFiles({
441
+ history: historyEntry,
442
+ outputNodeId,
443
+ outputKinds: params.outputKinds,
444
+ capability: params.capability
445
+ });
446
+ if (outputFiles.length === 0) throw new Error(`Comfy workflow ${promptId} completed without ${params.capability} outputs`);
447
+ const assets = [];
448
+ const outputKind = params.capability === "music" ? "audio" : params.capability;
449
+ const maxOutputBytes = resolveGeneratedMediaMaxBytes(params.cfg, outputKind);
450
+ let assetIndex = 0;
451
+ for (const output of outputFiles) {
452
+ const downloaded = await downloadOutputFile({
453
+ baseUrl: normalizedBaseUrl,
454
+ headers: new Headers(headers),
455
+ timeoutMs,
456
+ policy: networkPolicy.apiPolicy,
457
+ dispatcherPolicy,
458
+ file: output.file,
459
+ mode,
460
+ capability: params.capability,
461
+ maxBytes: maxOutputBytes
462
+ });
463
+ assetIndex += 1;
464
+ const originalName = normalizeOptionalString(output.file.filename) || normalizeOptionalString(output.file.name);
465
+ assets.push({
466
+ buffer: downloaded.buffer,
467
+ mimeType: downloaded.mimeType,
468
+ fileName: originalName || `${params.capability}-${assetIndex}.${resolveFileExtension({ mimeType: downloaded.mimeType })}`,
469
+ nodeId: output.nodeId
470
+ });
471
+ }
472
+ return {
473
+ assets,
474
+ model: providerModel,
475
+ promptId,
476
+ outputNodeIds: uniqueStrings(outputFiles.map((entry) => entry.nodeId))
477
+ };
478
+ }
479
+ //#endregion
480
+ export { DEFAULT_COMFY_MODEL, isComfyCapabilityConfigured, runComfyWorkflow };
@@ -0,0 +1,273 @@
1
+ {
2
+ "id": "comfy",
3
+ "activation": {
4
+ "onStartup": false
5
+ },
6
+ "enabledByDefault": true,
7
+ "providers": ["comfy"],
8
+ "setup": {
9
+ "providers": [
10
+ {
11
+ "id": "comfy",
12
+ "envVars": ["COMFY_API_KEY", "COMFY_CLOUD_API_KEY"]
13
+ }
14
+ ]
15
+ },
16
+ "providerAuthChoices": [
17
+ {
18
+ "provider": "comfy",
19
+ "method": "cloud-api-key",
20
+ "choiceId": "comfy-cloud-api-key",
21
+ "choiceLabel": "Comfy Cloud API key",
22
+ "choiceHint": "Required for cloud workflows",
23
+ "groupId": "comfy",
24
+ "groupLabel": "ComfyUI",
25
+ "groupHint": "Local or cloud workflows",
26
+ "optionKey": "comfyApiKey",
27
+ "cliFlag": "--comfy-api-key",
28
+ "cliOption": "--comfy-api-key <key>",
29
+ "cliDescription": "Comfy Cloud API key",
30
+ "onboardingScopes": ["image-generation"]
31
+ }
32
+ ],
33
+ "contracts": {
34
+ "imageGenerationProviders": ["comfy"],
35
+ "musicGenerationProviders": ["comfy"],
36
+ "videoGenerationProviders": ["comfy"]
37
+ },
38
+ "imageGenerationProviderMetadata": {
39
+ "comfy": {
40
+ "configSignals": [
41
+ {
42
+ "rootPath": "plugins.entries.comfy.config",
43
+ "overlayPath": "image",
44
+ "mode": {
45
+ "path": "mode",
46
+ "default": "local",
47
+ "allowed": ["local"]
48
+ },
49
+ "requiredAny": ["workflow", "workflowPath"],
50
+ "required": ["promptNodeId"]
51
+ },
52
+ {
53
+ "rootPath": "models.providers.comfy",
54
+ "overlayPath": "image",
55
+ "mode": {
56
+ "path": "mode",
57
+ "default": "local",
58
+ "allowed": ["local"]
59
+ },
60
+ "requiredAny": ["workflow", "workflowPath"],
61
+ "required": ["promptNodeId"]
62
+ },
63
+ {
64
+ "rootPath": "plugins.entries.comfy.config",
65
+ "overlayPath": "image",
66
+ "mode": {
67
+ "path": "mode",
68
+ "allowed": ["cloud"]
69
+ },
70
+ "requiredAny": ["workflow", "workflowPath"],
71
+ "required": ["promptNodeId", "apiKey"]
72
+ },
73
+ {
74
+ "rootPath": "models.providers.comfy",
75
+ "overlayPath": "image",
76
+ "mode": {
77
+ "path": "mode",
78
+ "allowed": ["cloud"]
79
+ },
80
+ "requiredAny": ["workflow", "workflowPath"],
81
+ "required": ["promptNodeId", "apiKey"]
82
+ }
83
+ ]
84
+ }
85
+ },
86
+ "musicGenerationProviderMetadata": {
87
+ "comfy": {
88
+ "configSignals": [
89
+ {
90
+ "rootPath": "plugins.entries.comfy.config",
91
+ "overlayPath": "music",
92
+ "mode": {
93
+ "path": "mode",
94
+ "default": "local",
95
+ "allowed": ["local"]
96
+ },
97
+ "requiredAny": ["workflow", "workflowPath"],
98
+ "required": ["promptNodeId"]
99
+ },
100
+ {
101
+ "rootPath": "models.providers.comfy",
102
+ "overlayPath": "music",
103
+ "mode": {
104
+ "path": "mode",
105
+ "default": "local",
106
+ "allowed": ["local"]
107
+ },
108
+ "requiredAny": ["workflow", "workflowPath"],
109
+ "required": ["promptNodeId"]
110
+ },
111
+ {
112
+ "rootPath": "plugins.entries.comfy.config",
113
+ "overlayPath": "music",
114
+ "mode": {
115
+ "path": "mode",
116
+ "allowed": ["cloud"]
117
+ },
118
+ "requiredAny": ["workflow", "workflowPath"],
119
+ "required": ["promptNodeId", "apiKey"]
120
+ },
121
+ {
122
+ "rootPath": "models.providers.comfy",
123
+ "overlayPath": "music",
124
+ "mode": {
125
+ "path": "mode",
126
+ "allowed": ["cloud"]
127
+ },
128
+ "requiredAny": ["workflow", "workflowPath"],
129
+ "required": ["promptNodeId", "apiKey"]
130
+ }
131
+ ]
132
+ }
133
+ },
134
+ "videoGenerationProviderMetadata": {
135
+ "comfy": {
136
+ "configSignals": [
137
+ {
138
+ "rootPath": "plugins.entries.comfy.config",
139
+ "overlayPath": "video",
140
+ "mode": {
141
+ "path": "mode",
142
+ "default": "local",
143
+ "allowed": ["local"]
144
+ },
145
+ "requiredAny": ["workflow", "workflowPath"],
146
+ "required": ["promptNodeId"]
147
+ },
148
+ {
149
+ "rootPath": "models.providers.comfy",
150
+ "overlayPath": "video",
151
+ "mode": {
152
+ "path": "mode",
153
+ "default": "local",
154
+ "allowed": ["local"]
155
+ },
156
+ "requiredAny": ["workflow", "workflowPath"],
157
+ "required": ["promptNodeId"]
158
+ },
159
+ {
160
+ "rootPath": "plugins.entries.comfy.config",
161
+ "overlayPath": "video",
162
+ "mode": {
163
+ "path": "mode",
164
+ "allowed": ["cloud"]
165
+ },
166
+ "requiredAny": ["workflow", "workflowPath"],
167
+ "required": ["promptNodeId", "apiKey"]
168
+ },
169
+ {
170
+ "rootPath": "models.providers.comfy",
171
+ "overlayPath": "video",
172
+ "mode": {
173
+ "path": "mode",
174
+ "allowed": ["cloud"]
175
+ },
176
+ "requiredAny": ["workflow", "workflowPath"],
177
+ "required": ["promptNodeId", "apiKey"]
178
+ }
179
+ ]
180
+ }
181
+ },
182
+ "configSchema": {
183
+ "type": "object",
184
+ "additionalProperties": false,
185
+ "properties": {
186
+ "mode": {
187
+ "type": "string",
188
+ "enum": ["local", "cloud"]
189
+ },
190
+ "baseUrl": {
191
+ "type": "string"
192
+ },
193
+ "apiKey": {
194
+ "type": ["string", "object"]
195
+ },
196
+ "allowPrivateNetwork": {
197
+ "type": "boolean"
198
+ },
199
+ "workflowPath": {
200
+ "type": "string"
201
+ },
202
+ "workflow": {
203
+ "type": "object"
204
+ },
205
+ "promptNodeId": {
206
+ "type": "string"
207
+ },
208
+ "promptInputName": {
209
+ "type": "string"
210
+ },
211
+ "inputImageNodeId": {
212
+ "type": "string"
213
+ },
214
+ "inputImageInputName": {
215
+ "type": "string"
216
+ },
217
+ "outputNodeId": {
218
+ "type": "string"
219
+ },
220
+ "pollIntervalMs": {
221
+ "type": "integer",
222
+ "minimum": 100
223
+ },
224
+ "timeoutMs": {
225
+ "type": "integer",
226
+ "minimum": 1000
227
+ },
228
+ "image": {
229
+ "type": "object",
230
+ "additionalProperties": false,
231
+ "properties": {
232
+ "workflowPath": { "type": "string" },
233
+ "workflow": { "type": "object" },
234
+ "promptNodeId": { "type": "string" },
235
+ "promptInputName": { "type": "string" },
236
+ "inputImageNodeId": { "type": "string" },
237
+ "inputImageInputName": { "type": "string" },
238
+ "outputNodeId": { "type": "string" },
239
+ "pollIntervalMs": { "type": "integer", "minimum": 100 },
240
+ "timeoutMs": { "type": "integer", "minimum": 1000 }
241
+ }
242
+ },
243
+ "video": {
244
+ "type": "object",
245
+ "additionalProperties": false,
246
+ "properties": {
247
+ "workflowPath": { "type": "string" },
248
+ "workflow": { "type": "object" },
249
+ "promptNodeId": { "type": "string" },
250
+ "promptInputName": { "type": "string" },
251
+ "inputImageNodeId": { "type": "string" },
252
+ "inputImageInputName": { "type": "string" },
253
+ "outputNodeId": { "type": "string" },
254
+ "pollIntervalMs": { "type": "integer", "minimum": 100 },
255
+ "timeoutMs": { "type": "integer", "minimum": 1000 }
256
+ }
257
+ },
258
+ "music": {
259
+ "type": "object",
260
+ "additionalProperties": false,
261
+ "properties": {
262
+ "workflowPath": { "type": "string" },
263
+ "workflow": { "type": "object" },
264
+ "promptNodeId": { "type": "string" },
265
+ "promptInputName": { "type": "string" },
266
+ "outputNodeId": { "type": "string" },
267
+ "pollIntervalMs": { "type": "integer", "minimum": 100 },
268
+ "timeoutMs": { "type": "integer", "minimum": 1000 }
269
+ }
270
+ }
271
+ }
272
+ }
273
+ }
package/package.json CHANGED
@@ -1,14 +1,49 @@
1
1
  {
2
2
  "name": "@openclaw/comfy-provider",
3
- "version": "0.0.0",
4
- "description": "Reserved package name for an official OpenClaw plugin.",
5
- "license": "MIT",
3
+ "version": "2026.8.1-beta.2",
4
+ "description": "OpenClaw ComfyUI provider plugin.",
6
5
  "repository": {
7
6
  "type": "git",
8
- "url": "git+https://github.com/openclaw/openclaw.git"
7
+ "url": "https://github.com/openclaw/openclaw"
9
8
  },
10
- "publishConfig": {
11
- "access": "public",
12
- "tag": "placeholder"
13
- }
9
+ "type": "module",
10
+ "openclaw": {
11
+ "extensions": [
12
+ "./index.ts"
13
+ ],
14
+ "install": {
15
+ "clawhubSpec": "clawhub:@openclaw/comfy-provider",
16
+ "npmSpec": "@openclaw/comfy-provider",
17
+ "defaultChoice": "npm",
18
+ "minHostVersion": ">=2026.7.2"
19
+ },
20
+ "compat": {
21
+ "pluginApi": ">=2026.8.1-beta.2"
22
+ },
23
+ "build": {
24
+ "openclawVersion": "2026.8.1-beta.2",
25
+ "bundledDist": false
26
+ },
27
+ "release": {
28
+ "publishToClawHub": true,
29
+ "publishToNpm": true
30
+ },
31
+ "runtimeExtensions": [
32
+ "./dist/index.js"
33
+ ]
34
+ },
35
+ "files": [
36
+ "dist/**",
37
+ "openclaw.plugin.json",
38
+ "README.md"
39
+ ],
40
+ "peerDependencies": {
41
+ "openclaw": ">=2026.8.1-beta.2"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "openclaw": {
45
+ "optional": true
46
+ }
47
+ },
48
+ "bundledDependencies": []
14
49
  }