@inductiv/node-red-openai-api
Version:
Enhance your Node-RED projects with advanced AI capabilities.
400 lines (354 loc) • 13.3 kB
JavaScript
"use strict";
// This file keeps the NOA-64 Images contract honest.
// It proves Image 2 requests stay pass-through where they should, streaming emits events, multi-image edits work, and the local help/examples describe the same contract.
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
function withMockedOpenAI(FakeOpenAI, callback) {
const openaiModule = require("openai");
const originalDescriptor = Object.getOwnPropertyDescriptor(openaiModule, "OpenAI");
Object.defineProperty(openaiModule, "OpenAI", {
value: FakeOpenAI,
configurable: true,
enumerable: true,
writable: true,
});
const run = async () => {
try {
return await callback();
} finally {
if (originalDescriptor) {
Object.defineProperty(openaiModule, "OpenAI", originalDescriptor);
}
}
};
return run();
}
function withMockedReadStreams(callback) {
const originalCreateReadStream = fs.createReadStream;
const createdStreams = [];
fs.createReadStream = (filePath) => {
const streamHandle = { kind: "read-stream", filePath };
createdStreams.push(streamHandle);
return streamHandle;
};
const run = async () => {
try {
await callback(createdStreams);
} finally {
fs.createReadStream = originalCreateReadStream;
}
};
return run();
}
function createFakeImageStream(events) {
return {
async *[Symbol.asyncIterator]() {
for (const event of events) {
yield event;
}
},
};
}
const imagesExample = JSON.parse(
fs.readFileSync(path.join(__dirname, "..", "examples", "images.json"), "utf8")
);
test("createImage forwards current Image 2 payloads unchanged and returns response.data", async () => {
const calls = [];
const requestPayload = {
model: "gpt-image-2",
prompt: "Create a widescreen transit poster with crisp typography.",
size: "1536x864",
background: "auto",
output_format: "png",
quality: "auto",
};
class FakeOpenAI {
constructor(clientParams) {
calls.push({ method: "ctor", clientParams });
this.images = {
generate: async (payload) => {
calls.push({ method: "images.generate", payload });
return {
data: [{ b64_json: "image_data_1" }],
};
},
};
}
}
await withMockedOpenAI(FakeOpenAI, async () => {
const modulePath = require.resolve("../src/images/methods.js");
delete require.cache[modulePath];
const imageMethods = require("../src/images/methods.js");
const clientContext = { clientParams: { apiKey: "sk-test" } };
const response = await imageMethods.createImage.call(clientContext, {
payload: requestPayload,
});
assert.deepEqual(response, [{ b64_json: "image_data_1" }]);
delete require.cache[modulePath];
});
assert.deepEqual(calls.filter((entry) => entry.method !== "ctor"), [
{
method: "images.generate",
payload: requestPayload,
},
]);
});
test("createImage emits streaming image events when stream is true", async () => {
const calls = [];
class FakeOpenAI {
constructor(clientParams) {
calls.push({ method: "ctor", clientParams });
this.images = {
generate: async (payload) => {
calls.push({ method: "images.generate", payload });
return createFakeImageStream([
{ type: "image.gen.partial_image", sequence_number: 1 },
{ type: "image.gen.completed", sequence_number: 2 },
]);
},
};
}
}
await withMockedOpenAI(FakeOpenAI, async () => {
const modulePath = require.resolve("../src/images/methods.js");
delete require.cache[modulePath];
const imageMethods = require("../src/images/methods.js");
const sentMessages = [];
const statuses = [];
const node = {
send: (msg) => sentMessages.push(msg),
status: (status) => statuses.push(status),
};
const result = await imageMethods.createImage.call(
{ clientParams: { apiKey: "sk-test" } },
{
_node: node,
msg: { topic: "image-stream" },
payload: {
model: "gpt-image-2",
prompt: "Create a layered magazine cover illustration.",
size: "1536x1024",
background: "auto",
stream: true,
partial_images: 2,
},
}
);
assert.equal(result, undefined);
assert.deepEqual(sentMessages, [
{
topic: "image-stream",
payload: { type: "image.gen.partial_image", sequence_number: 1 },
},
{
topic: "image-stream",
payload: { type: "image.gen.completed", sequence_number: 2 },
},
]);
assert.deepEqual(statuses, [
{
fill: "green",
shape: "dot",
text: "OpenaiApi.status.streaming",
},
{},
]);
delete require.cache[modulePath];
});
assert.deepEqual(calls.filter((entry) => entry.method !== "ctor"), [
{
method: "images.generate",
payload: {
model: "gpt-image-2",
prompt: "Create a layered magazine cover illustration.",
size: "1536x1024",
background: "auto",
stream: true,
partial_images: 2,
},
},
]);
});
test("createImageEdit preserves single-image behavior and converts mask paths to streams", async () => {
const calls = [];
class FakeOpenAI {
constructor(clientParams) {
calls.push({ method: "ctor", clientParams });
this.images = {
edit: async (payload) => {
calls.push({ method: "images.edit", payload });
return {
data: [{ b64_json: "edited_image" }],
};
},
};
}
}
await withMockedReadStreams(async () => {
await withMockedOpenAI(FakeOpenAI, async () => {
const modulePath = require.resolve("../src/images/methods.js");
delete require.cache[modulePath];
const imageMethods = require("../src/images/methods.js");
const response = await imageMethods.createImageEdit.call(
{ clientParams: { apiKey: "sk-test" } },
{
payload: {
image: "/tmp/source.png",
mask: "/tmp/mask.png",
prompt: "Add a blue dot in the middle.",
model: "gpt-image-1.5",
size: "1024x1024",
},
}
);
assert.deepEqual(response, [{ b64_json: "edited_image" }]);
delete require.cache[modulePath];
});
});
assert.deepEqual(calls.filter((entry) => entry.method !== "ctor"), [
{
method: "images.edit",
payload: {
image: { kind: "read-stream", filePath: "/tmp/source.png" },
mask: { kind: "read-stream", filePath: "/tmp/mask.png" },
prompt: "Add a blue dot in the middle.",
model: "gpt-image-1.5",
size: "1024x1024",
},
},
]);
});
test("createImageEdit accepts multiple image paths and emits edit stream events", async () => {
const calls = [];
class FakeOpenAI {
constructor(clientParams) {
calls.push({ method: "ctor", clientParams });
this.images = {
edit: async (payload) => {
calls.push({ method: "images.edit", payload });
return createFakeImageStream([
{ type: "image.edit.partial_image", sequence_number: 1 },
{ type: "image.edit.completed", sequence_number: 2 },
]);
},
};
}
}
await withMockedReadStreams(async () => {
await withMockedOpenAI(FakeOpenAI, async () => {
const modulePath = require.resolve("../src/images/methods.js");
delete require.cache[modulePath];
const imageMethods = require("../src/images/methods.js");
const sentMessages = [];
const statuses = [];
const node = {
send: (msg) => sentMessages.push(msg),
status: (status) => statuses.push(status),
};
const result = await imageMethods.createImageEdit.call(
{ clientParams: { apiKey: "sk-test" } },
{
_node: node,
msg: { topic: "image-edit-stream" },
payload: {
image: ["/tmp/source-1.png", "/tmp/source-2.png"],
prompt: "Blend these two source images into one poster composition.",
model: "gpt-image-2",
size: "1536x1024",
background: "auto",
stream: true,
partial_images: 1,
},
}
);
assert.equal(result, undefined);
assert.deepEqual(sentMessages, [
{
topic: "image-edit-stream",
payload: { type: "image.edit.partial_image", sequence_number: 1 },
},
{
topic: "image-edit-stream",
payload: { type: "image.edit.completed", sequence_number: 2 },
},
]);
assert.deepEqual(statuses, [
{
fill: "green",
shape: "dot",
text: "OpenaiApi.status.streaming",
},
{},
]);
delete require.cache[modulePath];
});
});
assert.deepEqual(calls.filter((entry) => entry.method !== "ctor"), [
{
method: "images.edit",
payload: {
image: [
{ kind: "read-stream", filePath: "/tmp/source-1.png" },
{ kind: "read-stream", filePath: "/tmp/source-2.png" },
],
prompt: "Blend these two source images into one poster composition.",
model: "gpt-image-2",
size: "1536x1024",
background: "auto",
stream: true,
partial_images: 1,
},
},
]);
});
test("Images example flow keeps the current Image 2 payloads importable", () => {
const createImageInject = imagesExample.find(
(node) => node.type === "inject" && node.name === "Create Image 2 Request"
);
assert.ok(createImageInject, "Expected Image 2 generate example inject node");
assert.deepEqual(createImageInject.props, [
{ p: "payload.model", v: "gpt-image-2", vt: "str" },
{
p: "payload.prompt",
v: "Create a widescreen transit poster with crisp typography and a sunrise skyline.",
vt: "str",
},
{ p: "payload.size", v: "1536x864", vt: "str" },
{ p: "payload.background", v: "auto", vt: "str" },
{ p: "payload.output_format", v: "png", vt: "str" },
]);
const streamInject = imagesExample.find(
(node) => node.type === "inject" && node.name === "Create Streaming Image 2 Request"
);
assert.ok(streamInject, "Expected streaming image example inject node");
assert.equal(
streamInject.props.find((prop) => prop.p === "payload.stream")?.v,
"true"
);
assert.equal(
streamInject.props.find((prop) => prop.p === "payload.partial_images")?.v,
"2"
);
const editInject = imagesExample.find(
(node) => node.type === "inject" && node.name === "Create Multi-Image Edit Request"
);
assert.ok(editInject, "Expected multi-image edit example inject node");
assert.equal(
editInject.props.find((prop) => prop.p === "payload.image[0]")?.v,
"/path/to/source-1.png"
);
assert.equal(
editInject.props.find((prop) => prop.p === "payload.image[1]")?.v,
"/path/to/source-2.png"
);
assert.equal(
editInject.props.find((prop) => prop.p === "payload.model")?.v,
"gpt-image-2"
);
assert.equal(
editInject.props.find((prop) => prop.p === "payload.background")?.v,
"auto"
);
});