Preserve existing workflows while adding capability-based catalogs, dedicated image requests, resumable native video, validated audio and explicit chat tiers. Cover compatibility, billing-sensitive retries, credentials and cancellation. Co-authored-by: Arthur Reboul Salze <22814805+ArthurReboulSalze@users.noreply.github.com> Co-authored-by: Dookie <me@djdookie.de>
233 lines
14 KiB
JavaScript
233 lines
14 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import { NODE_IDS, migrateWorkflow, WIDGET_NAMES, visibleInMode, modelOptions, scrubSerializedKey } from "../web/openrouter_workflow.js";
|
|
|
|
const old = JSON.parse(fs.readFileSync(new URL("../examples/chat_mode_example.json", import.meta.url)));
|
|
const before = structuredClone(old);
|
|
migrateWorkflow(old);
|
|
const node = old.nodes.find(n => n.type === "OpenRouterNode");
|
|
assert.deepEqual(node.outputs.map(o => o.name), ["Output", "image", "Stats", "Credits", "video"]);
|
|
assert.equal(old.links.find(l => l[0] === 2)[2], 2);
|
|
assert.equal(node.widgets_values[WIDGET_NAMES.indexOf("temperature")], before.nodes[0].widgets_values[7]);
|
|
assert.equal(node.widgets_values[WIDGET_NAMES.indexOf("chat_mode")], true);
|
|
assert.equal(node.widgets_values[WIDGET_NAMES.indexOf("request_type")], "chat");
|
|
const once = structuredClone(old);
|
|
migrateWorkflow(old);
|
|
assert.deepEqual(old, once, "migration must be idempotent");
|
|
|
|
// Real current-main order, including ComfyUI's seed control widget.
|
|
const currentValues = ["", "system", "prompt", "example/model", false, true, false,
|
|
"16:9 (1344x768)", "2K", "high", 123, "randomize", .7, "auto", true, 45];
|
|
const current = {nodes: [{id: 1, type: "OpenRouterNode", widgets_values: [...currentValues], outputs:
|
|
["Output", "image", "Stats", "Credits"].map(name => ({name}))}], links: [[1, 1, 2, 2, 0, "STRING"]]};
|
|
migrateWorkflow(current);
|
|
assert.deepEqual(current.nodes[0].widgets_values.slice(0, currentValues.length), currentValues);
|
|
assert.equal(current.links[0][2], 2, "current Stats links must not move");
|
|
assert.equal(current.nodes[0].widgets_values[16], "chat");
|
|
|
|
// Real historical INPUT_TYPES orders from b7501e7, d481e92 and ab115ee.
|
|
for (const [tail, expected] of [
|
|
[[true, .3, "mistral-ocr", true], {temperature: .3, pdf_engine: "mistral-ocr", chat_mode: true, image_resolution: "auto", seed: 0}],
|
|
[[true, "2K", 123, "randomize", .4, "auto", true], {temperature: .4, chat_mode: true, image_resolution: "2K", seed: 123, control_after_generate: "randomize"}],
|
|
[["4K", 456, "fixed", .5, "auto", false], {temperature: .5, chat_mode: false, image_resolution: "4K", seed: 456}],
|
|
]) {
|
|
const historicalImage = {nodes: [{id: 1, type: "OpenRouterNode", widgets_values: [...currentValues.slice(0, 7), ...tail],
|
|
outputs: ["Output", "image", "Stats", "Credits"].map(name => ({name}))}], links: []};
|
|
migrateWorkflow(historicalImage);
|
|
for (const [name, value] of Object.entries(expected)) {
|
|
assert.equal(historicalImage.nodes[0].widgets_values[WIDGET_NAMES.indexOf(name)], value, `legacy ${name}`);
|
|
}
|
|
assert.equal(historicalImage.nodes[0].widgets_values[WIDGET_NAMES.indexOf("request_type")], "chat");
|
|
}
|
|
|
|
assert(visibleInMode("system_prompt", "chat"));
|
|
assert(visibleInMode("user_message_box", "chat"));
|
|
assert(!visibleInMode("system_prompt", "image"));
|
|
assert(visibleInMode("video_duration", "video"));
|
|
assert(!visibleInMode("video_duration", "video", true));
|
|
assert(visibleInMode("video_job_id", "video", true));
|
|
assert.deepEqual(modelOptions({video: [{id: "new"}]}, "video", "saved"), ["saved", "new"]);
|
|
|
|
const live = {widgets: WIDGET_NAMES.map(name => ({name, value: name === "api_key" ? "secret" : "auto"}))};
|
|
live.widgets.find(w => w.name === "control_after_generate").options = {serialize: false};
|
|
live.widgets.push({name: "Refresh Models", serialize: false, options: {serialize: false}});
|
|
const serialized = {widgets_values: live.widgets.slice(0, -1).map(w => w.value), widgets_values_named: {api_key: "secret"}};
|
|
scrubSerializedKey(live, serialized);
|
|
assert.equal(serialized.widgets_values[0], "");
|
|
assert.equal(serialized.widgets_values_named.api_key, "", "named workflow values must not leak the key");
|
|
assert.equal(live.widgets[0].value, "secret", "execution keeps the session key");
|
|
assert.deepEqual(serialized.properties.openrouter_widget_names, WIDGET_NAMES);
|
|
|
|
const saved = {nodes: [{id: 1, type: "OpenRouterNode", ...structuredClone(serialized)}]};
|
|
saved.nodes[0].widgets_values[WIDGET_NAMES.indexOf("control_after_generate")] = "randomize";
|
|
saved.nodes[0].widgets_values[WIDGET_NAMES.indexOf("temperature")] = .4;
|
|
saved.nodes[0].widgets_values_named.temperature = .6;
|
|
migrateWorkflow(saved);
|
|
assert.equal(saved.nodes[0].widgets_values[WIDGET_NAMES.indexOf("control_after_generate")], "randomize");
|
|
assert.equal(saved.nodes[0].widgets_values[WIDGET_NAMES.indexOf("temperature")], .6, "named values take precedence on modern ComfyUI");
|
|
assert.equal(saved.nodes[0].widgets_values_named.temperature, .6);
|
|
|
|
const nested = {nodes: [], definitions: {subgraphs: [structuredClone(before)]}};
|
|
nested.definitions.subgraphs[0].floatingLinks = [{origin_id: 1, origin_slot: 2}];
|
|
migrateWorkflow(nested);
|
|
assert.equal(nested.definitions.subgraphs[0].nodes[0].outputs[2].name, "Stats");
|
|
assert.equal(nested.definitions.subgraphs[0].floatingLinks[0].origin_slot, 3);
|
|
|
|
// Exercise the real extension hooks with a minimal node and a mocked catalog.
|
|
// This catches wiring and serialization bugs that pure helper tests cannot.
|
|
let controls;
|
|
const app = {registerExtension(extension) { controls = extension; }};
|
|
const listeners = new Map();
|
|
const api = {addEventListener(name, callback) { listeners.set(name, callback); }, fetchApi: async () => ({ok: true, json: async () => ({
|
|
chat: [{id: "chat/model"},
|
|
{id: "openai/gpt-5.4-image-2", architecture: {output_modalities: ["text", "image"]}},
|
|
{id: "router/image-model", architecture: {output_modalities: ["image"]}},
|
|
{id: "google/gemini-3.1-flash-image-preview", architecture: {output_modalities: ["text", "image"]}}],
|
|
image: [{id: "image/model", supported_parameters: {resolution: {values: ["1024x1024"]}}},
|
|
{id: "openai/gpt-5.4-image-2", supported_parameters: {aspect_ratio: {values: ["1:1", "16:9", "auto"]}}},
|
|
{id: "google/gemini-3.1-flash-image-preview", supported_parameters: {resolution: {values: ["512", "1K", "2K", "4K"]}}}], video: [],
|
|
})})};
|
|
const controlsSource = fs.readFileSync(new URL("../web/openrouter_controls.js", import.meta.url), "utf8");
|
|
new Function("app", "api", "NODE_IDS", "migrateWorkflow", "visibleInMode", "modelOptions", "scrubSerializedKey",
|
|
"setInterval", controlsSource.replace(/^import .*$/gm, ""))(app, api, NODE_IDS, migrateWorkflow, visibleInMode, modelOptions, scrubSerializedKey, () => {});
|
|
class FakeNode {
|
|
constructor() {
|
|
this.id = 42;
|
|
this.properties = {};
|
|
this.widgets = WIDGET_NAMES.map(name => ({name, type: "combo", value: "auto", options: {values: ["auto"]}}));
|
|
this.widgets.find(w => w.name === "request_type").value = "chat";
|
|
this.widgets.find(w => w.name === "model").value = "chat/model";
|
|
this.widgets.find(w => w.name === "video_job_id").value = "";
|
|
this.widgets.find(w => w.name === "image_resolution").options.values = ["auto", "1K", "2K", "4K"];
|
|
this.widgets.find(w => w.name === "control_after_generate").options.serialize = false;
|
|
this.size = [400, 400];
|
|
this.graph = {setDirtyCanvas() {}, change() {}, getNodeById: id => String(id) === String(this.id) ? this : null};
|
|
}
|
|
addWidget(type, name, value, callback, options) {
|
|
const widget = {type, name, value, callback, options};
|
|
this.widgets.push(widget);
|
|
return widget;
|
|
}
|
|
computeSize() { return [400, 400]; }
|
|
setSize(value) { this.size = value; }
|
|
}
|
|
await controls.beforeRegisterNodeDef(FakeNode, {name: "OpenRouterNode"});
|
|
const controlled = new FakeNode();
|
|
app.graph = controlled.graph;
|
|
controlled.onNodeCreated();
|
|
controls.setup();
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
const widget = name => controlled.widgets.find(w => w.name === name);
|
|
assert.equal(widget("Refresh Models").serialize, false);
|
|
assert.equal(widget("Refresh Models").options.serialize, false);
|
|
assert.equal(widget("Resume Last Video").serialize, false);
|
|
assert.equal(widget("Resume Last Video").options.serialize, false);
|
|
assert.equal(widget("Resume Last Video").options.hidden, true);
|
|
widget("request_type").value = "image";
|
|
widget("model").value = "image/model";
|
|
widget("request_type").callback();
|
|
assert.deepEqual(widget("image_resolution").options.values, ["auto", "1024x1024"]);
|
|
assert.equal(widget("system_prompt").options.hidden, true);
|
|
widget("request_type").value = "chat";
|
|
widget("request_type").callback();
|
|
assert.deepEqual(widget("image_resolution").options.values, ["auto", "1K", "2K", "4K"]);
|
|
assert.equal(widget("system_prompt").options.hidden, false);
|
|
|
|
// Chat image settings follow the counterpart Image API capabilities, too.
|
|
widget("model").value = "openai/gpt-5.4-image-2:floor";
|
|
widget("image_resolution").value = "1K";
|
|
widget("model").callback();
|
|
assert.equal(widget("image_resolution").options.hidden, true, "unsupported inherited GPT resolution must be hidden");
|
|
assert.deepEqual(widget("aspect_ratio").options.values, ["auto", "1:1", "16:9"]);
|
|
assert.equal(widget("image_quality").options.hidden, true, "native-only image controls stay hidden in chat");
|
|
widget("image_resolution").value = "2K";
|
|
widget("model").callback();
|
|
assert.equal(widget("image_resolution").options.hidden, false, "unsupported explicit resolution remains visible for correction");
|
|
widget("model").value = "google/gemini-3.1-flash-image-preview";
|
|
widget("model").callback();
|
|
assert.deepEqual(widget("image_resolution").options.values, ["auto", "512", "1K", "2K", "4K"]);
|
|
widget("request_type").value = "image";
|
|
widget("request_type").callback();
|
|
assert.deepEqual(widget("image_resolution").options.values, ["auto", "512", "1K", "2K", "4K"], "native image resolution keeps catalog values");
|
|
widget("request_type").value = "chat";
|
|
widget("request_type").callback();
|
|
widget("model").value = "router/image-model";
|
|
widget("image_resolution").value = "auto";
|
|
widget("model").callback();
|
|
assert.equal(widget("image_resolution").options.hidden, true, "an image router without image metadata cannot promise resolution controls");
|
|
assert.equal(widget("aspect_ratio").options.hidden, true);
|
|
|
|
// Accepted jobs remain recoverable without changing subsequent submissions.
|
|
const videoJob = detail => listeners.get("openrouter.video_job")({detail});
|
|
videoJob({node_id: "42", job_id: "video-job_1"});
|
|
assert.equal(controlled.properties.openrouter_last_video_job_id, "video-job_1");
|
|
assert.equal(widget("video_job_id").value, "", "receiving a job must not enable recovery");
|
|
assert.equal(widget("request_type").value, "chat");
|
|
assert.equal(widget("Resume Last Video").options.hidden, true);
|
|
videoJob({node_id: "missing", job_id: "wrong-node"});
|
|
videoJob({node_id: "42", job_id: "https://not-a-job"});
|
|
assert.equal(controlled.properties.openrouter_last_video_job_id, "video-job_1");
|
|
widget("request_type").value = "video";
|
|
widget("request_type").callback();
|
|
assert.equal(widget("Resume Last Video").options.hidden, false);
|
|
widget("Resume Last Video").callback();
|
|
assert.equal(widget("video_job_id").value, "video-job_1");
|
|
assert.equal(widget("video_mode").options.hidden, true);
|
|
widget("video_job_id").value = "";
|
|
widget("video_job_id").callback();
|
|
videoJob({node_id: 42, job_id: "video-job_2"});
|
|
assert.equal(widget("video_job_id").value, "", "a new accepted job must still require an explicit resume click");
|
|
|
|
const savedVideo = {
|
|
widgets_values: controlled.widgets.filter(w => w.serialize !== false).map(w => w.value),
|
|
properties: structuredClone(controlled.properties),
|
|
};
|
|
controlled.onSerialize(savedVideo);
|
|
assert.deepEqual(savedVideo.properties.openrouter_widget_names, WIDGET_NAMES, "recovery button must not shift widget serialization");
|
|
const reopened = new FakeNode();
|
|
reopened.id = 43;
|
|
reopened.onNodeCreated();
|
|
reopened.properties = structuredClone(savedVideo.properties);
|
|
WIDGET_NAMES.forEach((name, index) => { reopened.widgets.find(w => w.name === name).value = savedVideo.widgets_values[index]; });
|
|
reopened.onConfigure();
|
|
assert.equal(reopened.widgets.find(w => w.name === "video_job_id").value, "");
|
|
assert.equal(reopened.widgets.find(w => w.name === "Resume Last Video").options.hidden, false);
|
|
reopened.widgets.find(w => w.name === "Resume Last Video").callback();
|
|
assert.equal(reopened.widgets.find(w => w.name === "video_job_id").value, "video-job_2", "saved last job must survive reopen");
|
|
|
|
// Resolve hierarchical execution IDs without touching a root node with the same local ID.
|
|
const nestedVideo = new FakeNode();
|
|
nestedVideo.onNodeCreated();
|
|
app.rootGraph = {getNodeById: id => id === "5" ? {subgraph: nestedVideo.graph} : controlled};
|
|
videoJob({node_id: "5:42", job_id: "nested-job"});
|
|
assert.equal(nestedVideo.properties.openrouter_last_video_job_id, "nested-job");
|
|
assert.equal(controlled.properties.openrouter_last_video_job_id, "video-job_2");
|
|
nestedVideo.onRemoved();
|
|
videoJob({node_id: "5:42", job_id: "removed-node"});
|
|
assert.equal(nestedVideo.properties.openrouter_last_video_job_id, "nested-job");
|
|
reopened.onRemoved();
|
|
controlled.onRemoved();
|
|
|
|
let dynamic;
|
|
const dynamicSource = fs.readFileSync(new URL("../web/openrouter_dynamic_inputs.js", import.meta.url), "utf8");
|
|
new Function("app", dynamicSource.replace(/^import .*$/gm, ""))({registerExtension(extension) { dynamic = extension; }});
|
|
class DynamicNode {
|
|
constructor() {
|
|
this.inputs = [{name: "image_resolution", type: "STRING", link: null}, {name: "audio_data", type: "AUDIO", link: null}];
|
|
this.widgets = [];
|
|
this.graph = {getNodeById() { return {outputs: [{type: "IMAGE"}]}; }, setDirtyCanvas() {}};
|
|
}
|
|
addInput(name, type) { this.inputs.push({name, type, link: null}); }
|
|
removeInput(index) { this.inputs.splice(index, 1); }
|
|
}
|
|
await dynamic.beforeRegisterNodeDef(DynamicNode, {name: "OpenRouterNode"});
|
|
const dynamicNode = new DynamicNode();
|
|
dynamicNode.onNodeCreated();
|
|
dynamicNode.onConnectionsChange(1, 0, true, {origin_id: 1, origin_slot: 0});
|
|
dynamicNode.onConnectionsChange(1, 1, true, {origin_id: 1, origin_slot: 0});
|
|
assert.deepEqual(dynamicNode.inputs.map(input => input.name), ["image_resolution", "audio_data", "image"]);
|
|
dynamicNode.inputs[2].link = 10;
|
|
dynamicNode.onConnectionsChange(1, 2, true, {origin_id: 1, origin_slot: 0});
|
|
assert.deepEqual(dynamicNode.inputs.map(input => input.name), ["image_resolution", "audio_data", "image_1", "image"]);
|
|
console.log("frontend workflow, mode visibility and key serialization checks passed");
|