UNPKG

@inductiv/node-red-openai-api

Version:

Enhance your Node-RED projects with advanced AI capabilities.

523 lines (452 loc) 13.7 kB
"use strict"; // This file covers the Responses websocket lifecycle in isolation. // It proves connect/send/close behavior, auth routing, parse errors, and node cleanup without needing a real network socket. const assert = require("node:assert/strict"); const EventEmitter = require("node:events"); const test = require("node:test"); const OpenaiApi = require("../lib.js"); const nodeModule = require("../node.js"); function withMockedWebSocket(FakeWebSocket, callback) { const wsModulePath = require.resolve("ws"); const methodsModulePath = require.resolve("../src/responses/methods.js"); const websocketModulePath = require.resolve("../src/responses/websocket.js"); const originalWsExports = require(wsModulePath); delete require.cache[methodsModulePath]; delete require.cache[websocketModulePath]; require.cache[wsModulePath].exports = { WebSocket: FakeWebSocket }; const run = async () => { try { const responsesMethods = require("../src/responses/methods.js"); return await callback(responsesMethods); } finally { delete require.cache[methodsModulePath]; delete require.cache[websocketModulePath]; require.cache[wsModulePath].exports = originalWsExports; } }; return run(); } class FakeWebSocket extends EventEmitter { constructor(url, options) { super(); this.url = url.toString(); this.options = options; this.sentPayloads = []; this.closeCalls = []; FakeWebSocket.instances.push(this); setImmediate(() => { this.emit("open"); }); } send(data) { this.sentPayloads.push(JSON.parse(data)); } close(code, reason) { this.closeCalls.push({ code, reason }); setImmediate(() => { this.emit("close", code, Buffer.from(reason)); }); } emitServerEvent(event) { this.emit("message", Buffer.from(JSON.stringify(event))); } emitRawMessage(raw) { this.emit("message", Buffer.from(raw)); } static reset() { FakeWebSocket.instances.length = 0; } } FakeWebSocket.instances = []; function createEvaluateNodeProperty() { return (value, type, node, msg, callback) => { let resolvedValue = value; if (type === "env" || type === "msg" || type === "flow" || type === "global") { resolvedValue = `resolved:${type}:${value}`; } if (typeof callback === "function") { callback(null, resolvedValue); return undefined; } return resolvedValue; }; } function createNodeHarness() { const registeredTypes = {}; const configNodes = new Map(); const RED = { nodes: { createNode: (node, config) => { const emitter = new EventEmitter(); node.on = emitter.on.bind(emitter); node.emit = emitter.emit.bind(emitter); node.sentMessages = []; node.errorMessages = []; node.send = (msg) => { node.sentMessages.push(msg); }; node.error = (error, msg) => { node.errorMessages.push({ error, msg }); }; node.status = () => { }; node.context = () => ({ flow: { get: () => undefined }, global: { get: () => undefined }, }); node.credentials = config.credentials || {}; node.id = config.id; }, registerType: (name, ctor) => { registeredTypes[name] = ctor; }, getNode: (id) => configNodes.get(id), }, util: { evaluateNodeProperty: createEvaluateNodeProperty(), getMessageProperty: (msg, path) => { if (path === "payload") { return msg.payload; } return path.split(".").reduce((value, key) => { if (value === undefined || value === null) { return undefined; } return value[key]; }, msg); }, }, }; nodeModule(RED); return { OpenaiApiNode: registeredTypes["OpenAI API"], ServiceHostNode: registeredTypes["Service Host"], configNodes, }; } async function nextTick() { await new Promise((resolve) => setImmediate(resolve)); } test("responses websocket connect/send/close uses custom auth header and emits server events", async () => { FakeWebSocket.reset(); await withMockedWebSocket(FakeWebSocket, async (responsesMethods) => { const sentMessages = []; const errorMessages = []; const cleanupHandlers = []; const node = { send: (msg) => sentMessages.push(msg), error: (error) => errorMessages.push(error), registerCleanupHandler: (handler) => cleanupHandlers.push(handler), }; const clientContext = { clientParams: { apiKey: "sk-test", baseURL: "http://api.example.com/v1", organization: "org_test", defaultHeaders: { Authorization: null, "X-API-Key": "sk-test", }, }, }; const connectResponse = await responsesMethods.manageModelResponseWebSocket.call( clientContext, { _node: node, payload: { action: "connect", connection_id: "connection-1", }, } ); const socket = FakeWebSocket.instances[0]; assert.ok(socket); assert.deepEqual(connectResponse, { object: "response.websocket.connection", action: "connect", connection_id: "connection-1", url: "ws://api.example.com/v1/responses", }); assert.deepEqual(socket.options, { headers: { "X-API-Key": "sk-test", "OpenAI-Organization": "org_test", }, }); assert.equal(cleanupHandlers.length, 1); socket.emitServerEvent({ type: "response.created", response: { id: "resp_1" } }); await nextTick(); assert.deepEqual(sentMessages, [ { payload: { type: "response.created", response: { id: "resp_1" } }, openai: { transport: "responses.websocket", direction: "server", connection_id: "connection-1", event_type: "response.created", }, }, ]); const sendResponse = await responsesMethods.manageModelResponseWebSocket.call( clientContext, { _node: node, payload: { action: "send", connection_id: "connection-1", event: { type: "response.create", model: "gpt-5.4", input: "Say hello from the websocket test.", }, }, } ); assert.deepEqual(socket.sentPayloads, [ { type: "response.create", model: "gpt-5.4", input: "Say hello from the websocket test.", }, ]); assert.deepEqual(sendResponse, { object: "response.websocket.client_event", action: "send", connection_id: "connection-1", event_type: "response.create", }); const closeResponse = await responsesMethods.manageModelResponseWebSocket.call( clientContext, { _node: node, payload: { action: "close", connection_id: "connection-1", reason: "Example close", }, } ); assert.deepEqual(socket.closeCalls, [ { code: 1000, reason: "Example close", }, ]); assert.deepEqual(closeResponse, { object: "response.websocket.connection", action: "close", connection_id: "connection-1", code: 1000, reason: "Example close", }); assert.equal(errorMessages.length, 0); }); }); test("responses websocket uses query auth and cleanup handlers close active connections", async () => { FakeWebSocket.reset(); await withMockedWebSocket(FakeWebSocket, async (responsesMethods) => { const cleanupHandlers = []; const node = { send: () => { }, error: () => { }, registerCleanupHandler: (handler) => cleanupHandlers.push(handler), }; const clientContext = { clientParams: { apiKey: "sk-test", baseURL: "https://api.example.com/v1", defaultHeaders: { Authorization: null, }, defaultQuery: { api_token: "sk-test", }, }, }; await responsesMethods.manageModelResponseWebSocket.call(clientContext, { _node: node, payload: { action: "connect", connection_id: "connection-query", }, }); const socket = FakeWebSocket.instances[0]; assert.ok(socket); assert.equal( socket.url, "wss://api.example.com/v1/responses?api_token=sk-test" ); assert.deepEqual(socket.options, { headers: {}, }); assert.equal(cleanupHandlers.length, 1); await cleanupHandlers[0](); assert.deepEqual(socket.closeCalls, [ { code: 1000, reason: "Node-RED node closed", }, ]); }); }); test("responses websocket preserves base URL query parameters when building the connection URL", async () => { FakeWebSocket.reset(); await withMockedWebSocket(FakeWebSocket, async (responsesMethods) => { const node = { send: () => { }, error: () => { }, registerCleanupHandler: () => { }, }; const clientContext = { clientParams: { apiKey: "sk-test", baseURL: "https://api.example.com/v1?api-version=2026-01-01", defaultQuery: { api_token: "sk-test", }, defaultHeaders: { Authorization: null, }, }, }; const connectResponse = await responsesMethods.manageModelResponseWebSocket.call( clientContext, { _node: node, payload: { action: "connect", connection_id: "connection-query-base", }, } ); const socket = FakeWebSocket.instances[0]; assert.ok(socket); assert.equal( connectResponse.url, "wss://api.example.com/v1/responses?api-version=2026-01-01&api_token=sk-test" ); assert.equal( socket.url, "wss://api.example.com/v1/responses?api-version=2026-01-01&api_token=sk-test" ); assert.deepEqual(socket.options, { headers: {}, }); }); }); test("responses websocket validates action, event shape, and parse errors", async () => { FakeWebSocket.reset(); await withMockedWebSocket(FakeWebSocket, async (responsesMethods) => { await assert.rejects( responsesMethods.manageModelResponseWebSocket.call( { clientParams: { apiKey: "sk-test" } }, { _node: { send: () => { }, error: () => { }, registerCleanupHandler: () => { }, }, payload: {}, } ), /msg\.payload\.action must be one of 'connect', 'send', or 'close'/ ); const errorMessages = []; const node = { send: () => { }, error: (error) => errorMessages.push(error), registerCleanupHandler: () => { }, }; await responsesMethods.manageModelResponseWebSocket.call( { clientParams: { apiKey: "sk-test" } }, { _node: node, payload: { action: "connect", connection_id: "connection-errors", }, } ); const socket = FakeWebSocket.instances[0]; await assert.rejects( responsesMethods.manageModelResponseWebSocket.call( { clientParams: { apiKey: "sk-test" } }, { _node: node, payload: { action: "send", connection_id: "connection-errors", event: { type: "response.cancel", }, }, } ), /msg\.payload\.event\.type must be 'response\.create'/ ); socket.emitRawMessage("not-json"); await nextTick(); assert.equal(errorMessages.length, 1); assert.match(errorMessages[0].message, /Could not parse Responses websocket event/); }); }); test("OpenAI API node runs registered cleanup handlers on close", async () => { const originalManageModelResponseWebSocket = OpenaiApi.prototype.manageModelResponseWebSocket; let cleanupCalls = 0; OpenaiApi.prototype.manageModelResponseWebSocket = async function ({ _node }) { _node.registerCleanupHandler(async () => { cleanupCalls += 1; }); return { ok: true }; }; try { const harness = createNodeHarness(); assert.ok(harness.OpenaiApiNode, "OpenAI API node should register"); assert.ok(harness.ServiceHostNode, "Service Host node should register"); const serviceNode = new harness.ServiceHostNode({ id: "service-1", apiBase: "https://api.example.com/v1", apiBaseType: "str", secureApiKeyHeaderOrQueryName: "Authorization", secureApiKeyHeaderOrQueryNameType: "str", organizationId: "", organizationIdType: "str", secureApiKeyIsQuery: false, secureApiKeyValueType: "cred", credentials: { secureApiKeyValue: "sk-test", }, }); harness.configNodes.set("service-1", serviceNode); const apiNode = new harness.OpenaiApiNode({ id: "openai-1", service: "service-1", method: "manageModelResponseWebSocket", property: "payload", propertyType: "msg", }); apiNode.emit("input", { payload: { action: "connect", connection_id: "connection-1", }, }); await nextTick(); await nextTick(); assert.equal(apiNode.errorMessages.length, 0); assert.equal(apiNode.sentMessages.length, 1); await new Promise((resolve, reject) => { apiNode.emit("close", (error) => { if (error) { reject(error); return; } resolve(); }); }); assert.equal(cleanupCalls, 1); } finally { OpenaiApi.prototype.manageModelResponseWebSocket = originalManageModelResponseWebSocket; } });