@inductiv/node-red-openai-api 6.22.0 → 6.27.0

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.
@@ -0,0 +1,472 @@
1
+ "use strict";
2
+
3
+ // This file covers the Responses websocket lifecycle in isolation.
4
+ // It proves connect/send/close behavior, auth routing, parse errors, and node cleanup without needing a real network socket.
5
+
6
+ const assert = require("node:assert/strict");
7
+ const EventEmitter = require("node:events");
8
+ const test = require("node:test");
9
+
10
+ const OpenaiApi = require("../lib.js");
11
+ const nodeModule = require("../node.js");
12
+
13
+ function withMockedWebSocket(FakeWebSocket, callback) {
14
+ const wsModulePath = require.resolve("ws");
15
+ const methodsModulePath = require.resolve("../src/responses/methods.js");
16
+ const websocketModulePath = require.resolve("../src/responses/websocket.js");
17
+ const originalWsExports = require(wsModulePath);
18
+
19
+ delete require.cache[methodsModulePath];
20
+ delete require.cache[websocketModulePath];
21
+ require.cache[wsModulePath].exports = { WebSocket: FakeWebSocket };
22
+
23
+ const run = async () => {
24
+ try {
25
+ const responsesMethods = require("../src/responses/methods.js");
26
+ return await callback(responsesMethods);
27
+ } finally {
28
+ delete require.cache[methodsModulePath];
29
+ delete require.cache[websocketModulePath];
30
+ require.cache[wsModulePath].exports = originalWsExports;
31
+ }
32
+ };
33
+
34
+ return run();
35
+ }
36
+
37
+ class FakeWebSocket extends EventEmitter {
38
+ constructor(url, options) {
39
+ super();
40
+ this.url = url.toString();
41
+ this.options = options;
42
+ this.sentPayloads = [];
43
+ this.closeCalls = [];
44
+
45
+ FakeWebSocket.instances.push(this);
46
+ setImmediate(() => {
47
+ this.emit("open");
48
+ });
49
+ }
50
+
51
+ send(data) {
52
+ this.sentPayloads.push(JSON.parse(data));
53
+ }
54
+
55
+ close(code, reason) {
56
+ this.closeCalls.push({ code, reason });
57
+ setImmediate(() => {
58
+ this.emit("close", code, Buffer.from(reason));
59
+ });
60
+ }
61
+
62
+ emitServerEvent(event) {
63
+ this.emit("message", Buffer.from(JSON.stringify(event)));
64
+ }
65
+
66
+ emitRawMessage(raw) {
67
+ this.emit("message", Buffer.from(raw));
68
+ }
69
+
70
+ static reset() {
71
+ FakeWebSocket.instances.length = 0;
72
+ }
73
+ }
74
+
75
+ FakeWebSocket.instances = [];
76
+
77
+ function createEvaluateNodeProperty() {
78
+ return (value, type, node, msg, callback) => {
79
+ let resolvedValue = value;
80
+
81
+ if (type === "env" || type === "msg" || type === "flow" || type === "global") {
82
+ resolvedValue = `resolved:${type}:${value}`;
83
+ }
84
+
85
+ if (typeof callback === "function") {
86
+ callback(null, resolvedValue);
87
+ return undefined;
88
+ }
89
+
90
+ return resolvedValue;
91
+ };
92
+ }
93
+
94
+ function createNodeHarness() {
95
+ const registeredTypes = {};
96
+ const configNodes = new Map();
97
+
98
+ const RED = {
99
+ nodes: {
100
+ createNode: (node, config) => {
101
+ const emitter = new EventEmitter();
102
+ node.on = emitter.on.bind(emitter);
103
+ node.emit = emitter.emit.bind(emitter);
104
+
105
+ node.sentMessages = [];
106
+ node.errorMessages = [];
107
+ node.send = (msg) => {
108
+ node.sentMessages.push(msg);
109
+ };
110
+ node.error = (error, msg) => {
111
+ node.errorMessages.push({ error, msg });
112
+ };
113
+ node.status = () => { };
114
+ node.context = () => ({
115
+ flow: { get: () => undefined },
116
+ global: { get: () => undefined },
117
+ });
118
+
119
+ node.credentials = config.credentials || {};
120
+ node.id = config.id;
121
+ },
122
+ registerType: (name, ctor) => {
123
+ registeredTypes[name] = ctor;
124
+ },
125
+ getNode: (id) => configNodes.get(id),
126
+ },
127
+ util: {
128
+ evaluateNodeProperty: createEvaluateNodeProperty(),
129
+ getMessageProperty: (msg, path) => {
130
+ if (path === "payload") {
131
+ return msg.payload;
132
+ }
133
+
134
+ return path.split(".").reduce((value, key) => {
135
+ if (value === undefined || value === null) {
136
+ return undefined;
137
+ }
138
+ return value[key];
139
+ }, msg);
140
+ },
141
+ },
142
+ };
143
+
144
+ nodeModule(RED);
145
+
146
+ return {
147
+ OpenaiApiNode: registeredTypes["OpenAI API"],
148
+ ServiceHostNode: registeredTypes["Service Host"],
149
+ configNodes,
150
+ };
151
+ }
152
+
153
+ async function nextTick() {
154
+ await new Promise((resolve) => setImmediate(resolve));
155
+ }
156
+
157
+ test("responses websocket connect/send/close uses custom auth header and emits server events", async () => {
158
+ FakeWebSocket.reset();
159
+
160
+ await withMockedWebSocket(FakeWebSocket, async (responsesMethods) => {
161
+ const sentMessages = [];
162
+ const errorMessages = [];
163
+ const cleanupHandlers = [];
164
+ const node = {
165
+ send: (msg) => sentMessages.push(msg),
166
+ error: (error) => errorMessages.push(error),
167
+ registerCleanupHandler: (handler) => cleanupHandlers.push(handler),
168
+ };
169
+
170
+ const clientContext = {
171
+ clientParams: {
172
+ apiKey: "sk-test",
173
+ baseURL: "http://api.example.com/v1",
174
+ organization: "org_test",
175
+ defaultHeaders: {
176
+ Authorization: null,
177
+ "X-API-Key": "sk-test",
178
+ },
179
+ },
180
+ };
181
+
182
+ const connectResponse = await responsesMethods.manageModelResponseWebSocket.call(
183
+ clientContext,
184
+ {
185
+ _node: node,
186
+ payload: {
187
+ action: "connect",
188
+ connection_id: "connection-1",
189
+ },
190
+ }
191
+ );
192
+
193
+ const socket = FakeWebSocket.instances[0];
194
+ assert.ok(socket);
195
+ assert.deepEqual(connectResponse, {
196
+ object: "response.websocket.connection",
197
+ action: "connect",
198
+ connection_id: "connection-1",
199
+ url: "ws://api.example.com/v1/responses",
200
+ });
201
+ assert.deepEqual(socket.options, {
202
+ headers: {
203
+ "X-API-Key": "sk-test",
204
+ "OpenAI-Organization": "org_test",
205
+ },
206
+ });
207
+ assert.equal(cleanupHandlers.length, 1);
208
+
209
+ socket.emitServerEvent({ type: "response.created", response: { id: "resp_1" } });
210
+ await nextTick();
211
+
212
+ assert.deepEqual(sentMessages, [
213
+ {
214
+ payload: { type: "response.created", response: { id: "resp_1" } },
215
+ openai: {
216
+ transport: "responses.websocket",
217
+ direction: "server",
218
+ connection_id: "connection-1",
219
+ event_type: "response.created",
220
+ },
221
+ },
222
+ ]);
223
+
224
+ const sendResponse = await responsesMethods.manageModelResponseWebSocket.call(
225
+ clientContext,
226
+ {
227
+ _node: node,
228
+ payload: {
229
+ action: "send",
230
+ connection_id: "connection-1",
231
+ event: {
232
+ type: "response.create",
233
+ model: "gpt-5.4",
234
+ input: "Say hello from the websocket test.",
235
+ },
236
+ },
237
+ }
238
+ );
239
+
240
+ assert.deepEqual(socket.sentPayloads, [
241
+ {
242
+ type: "response.create",
243
+ model: "gpt-5.4",
244
+ input: "Say hello from the websocket test.",
245
+ },
246
+ ]);
247
+ assert.deepEqual(sendResponse, {
248
+ object: "response.websocket.client_event",
249
+ action: "send",
250
+ connection_id: "connection-1",
251
+ event_type: "response.create",
252
+ });
253
+
254
+ const closeResponse = await responsesMethods.manageModelResponseWebSocket.call(
255
+ clientContext,
256
+ {
257
+ _node: node,
258
+ payload: {
259
+ action: "close",
260
+ connection_id: "connection-1",
261
+ reason: "Example close",
262
+ },
263
+ }
264
+ );
265
+
266
+ assert.deepEqual(socket.closeCalls, [
267
+ {
268
+ code: 1000,
269
+ reason: "Example close",
270
+ },
271
+ ]);
272
+ assert.deepEqual(closeResponse, {
273
+ object: "response.websocket.connection",
274
+ action: "close",
275
+ connection_id: "connection-1",
276
+ code: 1000,
277
+ reason: "Example close",
278
+ });
279
+ assert.equal(errorMessages.length, 0);
280
+ });
281
+ });
282
+
283
+ test("responses websocket uses query auth and cleanup handlers close active connections", async () => {
284
+ FakeWebSocket.reset();
285
+
286
+ await withMockedWebSocket(FakeWebSocket, async (responsesMethods) => {
287
+ const cleanupHandlers = [];
288
+ const node = {
289
+ send: () => { },
290
+ error: () => { },
291
+ registerCleanupHandler: (handler) => cleanupHandlers.push(handler),
292
+ };
293
+
294
+ const clientContext = {
295
+ clientParams: {
296
+ apiKey: "sk-test",
297
+ baseURL: "https://api.example.com/v1",
298
+ defaultHeaders: {
299
+ Authorization: null,
300
+ },
301
+ defaultQuery: {
302
+ api_token: "sk-test",
303
+ },
304
+ },
305
+ };
306
+
307
+ await responsesMethods.manageModelResponseWebSocket.call(clientContext, {
308
+ _node: node,
309
+ payload: {
310
+ action: "connect",
311
+ connection_id: "connection-query",
312
+ },
313
+ });
314
+
315
+ const socket = FakeWebSocket.instances[0];
316
+ assert.ok(socket);
317
+ assert.equal(
318
+ socket.url,
319
+ "wss://api.example.com/v1/responses?api_token=sk-test"
320
+ );
321
+ assert.deepEqual(socket.options, {
322
+ headers: {},
323
+ });
324
+ assert.equal(cleanupHandlers.length, 1);
325
+
326
+ await cleanupHandlers[0]();
327
+
328
+ assert.deepEqual(socket.closeCalls, [
329
+ {
330
+ code: 1000,
331
+ reason: "Node-RED node closed",
332
+ },
333
+ ]);
334
+ });
335
+ });
336
+
337
+ test("responses websocket validates action, event shape, and parse errors", async () => {
338
+ FakeWebSocket.reset();
339
+
340
+ await withMockedWebSocket(FakeWebSocket, async (responsesMethods) => {
341
+ await assert.rejects(
342
+ responsesMethods.manageModelResponseWebSocket.call(
343
+ { clientParams: { apiKey: "sk-test" } },
344
+ {
345
+ _node: {
346
+ send: () => { },
347
+ error: () => { },
348
+ registerCleanupHandler: () => { },
349
+ },
350
+ payload: {},
351
+ }
352
+ ),
353
+ /msg\.payload\.action must be one of 'connect', 'send', or 'close'/
354
+ );
355
+
356
+ const errorMessages = [];
357
+ const node = {
358
+ send: () => { },
359
+ error: (error) => errorMessages.push(error),
360
+ registerCleanupHandler: () => { },
361
+ };
362
+
363
+ await responsesMethods.manageModelResponseWebSocket.call(
364
+ { clientParams: { apiKey: "sk-test" } },
365
+ {
366
+ _node: node,
367
+ payload: {
368
+ action: "connect",
369
+ connection_id: "connection-errors",
370
+ },
371
+ }
372
+ );
373
+
374
+ const socket = FakeWebSocket.instances[0];
375
+
376
+ await assert.rejects(
377
+ responsesMethods.manageModelResponseWebSocket.call(
378
+ { clientParams: { apiKey: "sk-test" } },
379
+ {
380
+ _node: node,
381
+ payload: {
382
+ action: "send",
383
+ connection_id: "connection-errors",
384
+ event: {
385
+ type: "response.cancel",
386
+ },
387
+ },
388
+ }
389
+ ),
390
+ /msg\.payload\.event\.type must be 'response\.create'/
391
+ );
392
+
393
+ socket.emitRawMessage("not-json");
394
+ await nextTick();
395
+
396
+ assert.equal(errorMessages.length, 1);
397
+ assert.match(errorMessages[0].message, /Could not parse Responses websocket event/);
398
+ });
399
+ });
400
+
401
+ test("OpenAI API node runs registered cleanup handlers on close", async () => {
402
+ const originalManageModelResponseWebSocket =
403
+ OpenaiApi.prototype.manageModelResponseWebSocket;
404
+ let cleanupCalls = 0;
405
+
406
+ OpenaiApi.prototype.manageModelResponseWebSocket = async function ({ _node }) {
407
+ _node.registerCleanupHandler(async () => {
408
+ cleanupCalls += 1;
409
+ });
410
+
411
+ return { ok: true };
412
+ };
413
+
414
+ try {
415
+ const harness = createNodeHarness();
416
+ assert.ok(harness.OpenaiApiNode, "OpenAI API node should register");
417
+ assert.ok(harness.ServiceHostNode, "Service Host node should register");
418
+
419
+ const serviceNode = new harness.ServiceHostNode({
420
+ id: "service-1",
421
+ apiBase: "https://api.example.com/v1",
422
+ apiBaseType: "str",
423
+ secureApiKeyHeaderOrQueryName: "Authorization",
424
+ secureApiKeyHeaderOrQueryNameType: "str",
425
+ organizationId: "",
426
+ organizationIdType: "str",
427
+ secureApiKeyIsQuery: false,
428
+ secureApiKeyValueType: "cred",
429
+ credentials: {
430
+ secureApiKeyValue: "sk-test",
431
+ },
432
+ });
433
+ harness.configNodes.set("service-1", serviceNode);
434
+
435
+ const apiNode = new harness.OpenaiApiNode({
436
+ id: "openai-1",
437
+ service: "service-1",
438
+ method: "manageModelResponseWebSocket",
439
+ property: "payload",
440
+ propertyType: "msg",
441
+ });
442
+
443
+ apiNode.emit("input", {
444
+ payload: {
445
+ action: "connect",
446
+ connection_id: "connection-1",
447
+ },
448
+ });
449
+
450
+ await nextTick();
451
+ await nextTick();
452
+
453
+ assert.equal(apiNode.errorMessages.length, 0);
454
+ assert.equal(apiNode.sentMessages.length, 1);
455
+
456
+ await new Promise((resolve, reject) => {
457
+ apiNode.emit("close", (error) => {
458
+ if (error) {
459
+ reject(error);
460
+ return;
461
+ }
462
+
463
+ resolve();
464
+ });
465
+ });
466
+
467
+ assert.equal(cleanupCalls, 1);
468
+ } finally {
469
+ OpenaiApi.prototype.manageModelResponseWebSocket =
470
+ originalManageModelResponseWebSocket;
471
+ }
472
+ });
@@ -1,5 +1,8 @@
1
1
  "use strict";
2
2
 
3
+ // This file is about the editor-side Service Host experience.
4
+ // It checks that the config UI still exposes the typed-input behavior we rely on for API keys and related fields.
5
+
3
6
  const assert = require("node:assert/strict");
4
7
  const fs = require("node:fs");
5
8
  const path = require("node:path");
@@ -1,5 +1,8 @@
1
1
  "use strict";
2
2
 
3
+ // This file covers the runtime side of the Service Host config node.
4
+ // It makes sure typed values resolve the right way, including the older storage formats we still support.
5
+
3
6
  const assert = require("node:assert/strict");
4
7
  const test = require("node:test");
5
8
 
@@ -1,5 +1,8 @@
1
1
  "use strict";
2
2
 
3
+ // This is a legacy high-level service test file.
4
+ // It exercises the older service factory layer and mocked API client behavior in a more end-to-end style.
5
+
3
6
  const assert = require("assert");
4
7
  const { createClient } = require("../src/client");
5
8
  const { createApi } = require("../src/index");
@@ -1,5 +1,8 @@
1
1
  "use strict";
2
2
 
3
+ // This is a legacy utility test file.
4
+ // It checks the small helper surfaces like validation, streaming helpers, and file utilities in plain terms.
5
+
3
6
  const assert = require("assert");
4
7
  const { validation, streaming, fileHelpers } = require("../src/index").utils;
5
8