@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.
@@ -1,4 +1,164 @@
1
1
  const OpenAI = require("openai").OpenAI;
2
+ const { ResponsesWebSocket } = require("./websocket.js");
3
+
4
+ function getResponsesWebSocketConnections(node) {
5
+ if (!node._responsesWebSocketConnections) {
6
+ node._responsesWebSocketConnections = new Map();
7
+ }
8
+
9
+ return node._responsesWebSocketConnections;
10
+ }
11
+
12
+ function ensureResponsesWebSocketCleanup(node) {
13
+ if (node._responsesWebSocketCleanupRegistered) {
14
+ return;
15
+ }
16
+
17
+ if (typeof node.registerCleanupHandler !== "function") {
18
+ throw new Error("OpenAI API node does not support cleanup registration");
19
+ }
20
+
21
+ node._responsesWebSocketCleanupRegistered = true;
22
+ node.registerCleanupHandler(async () => {
23
+ const connections = getResponsesWebSocketConnections(node);
24
+ const closeOperations = [];
25
+
26
+ for (const connection of connections.values()) {
27
+ closeOperations.push(
28
+ connection.close({
29
+ code: 1000,
30
+ reason: "Node-RED node closed",
31
+ })
32
+ );
33
+ }
34
+
35
+ await Promise.all(closeOperations);
36
+ connections.clear();
37
+ });
38
+ }
39
+
40
+ function requireConnectionId(payload) {
41
+ if (typeof payload.connection_id !== "string" || payload.connection_id.trim() === "") {
42
+ throw new Error("msg.payload.connection_id must be a non-empty string");
43
+ }
44
+
45
+ return payload.connection_id;
46
+ }
47
+
48
+ function createResponsesWebSocketEventMessage(connectionId, event) {
49
+ return {
50
+ payload: event,
51
+ openai: {
52
+ transport: "responses.websocket",
53
+ direction: "server",
54
+ connection_id: connectionId,
55
+ event_type: event.type,
56
+ },
57
+ };
58
+ }
59
+
60
+ function attachResponsesWebSocketListeners(node, connectionId, connection) {
61
+ connection.on("event", (event) => {
62
+ node.send(createResponsesWebSocketEventMessage(connectionId, event));
63
+ });
64
+
65
+ connection.on("error", (error) => {
66
+ node.error(error);
67
+ });
68
+
69
+ connection.on("close", () => {
70
+ const connections = getResponsesWebSocketConnections(node);
71
+ connections.delete(connectionId);
72
+ });
73
+ }
74
+
75
+ async function connectResponsesWebSocket(parameters) {
76
+ const node = parameters._node;
77
+ const payload = parameters.payload || {};
78
+ const connectionId = requireConnectionId(payload);
79
+ const connections = getResponsesWebSocketConnections(node);
80
+
81
+ if (connections.has(connectionId)) {
82
+ throw new Error(
83
+ `Responses websocket connection '${connectionId}' is already open on this node`
84
+ );
85
+ }
86
+
87
+ ensureResponsesWebSocketCleanup(node);
88
+
89
+ const connection = new ResponsesWebSocket(this.clientParams);
90
+ const connectionDetails = await connection.open();
91
+
92
+ attachResponsesWebSocketListeners(node, connectionId, connection);
93
+ connections.set(connectionId, connection);
94
+
95
+ return {
96
+ object: "response.websocket.connection",
97
+ action: "connect",
98
+ connection_id: connectionId,
99
+ url: connectionDetails.url,
100
+ };
101
+ }
102
+
103
+ async function sendResponsesWebSocketEvent(parameters) {
104
+ const node = parameters._node;
105
+ const payload = parameters.payload || {};
106
+ const connectionId = requireConnectionId(payload);
107
+ const connections = getResponsesWebSocketConnections(node);
108
+ const connection = connections.get(connectionId);
109
+
110
+ if (!connection) {
111
+ throw new Error(
112
+ `Responses websocket connection '${connectionId}' is not open on this node`
113
+ );
114
+ }
115
+
116
+ if (!payload.event || typeof payload.event !== "object" || Array.isArray(payload.event)) {
117
+ throw new Error("msg.payload.event must be an object");
118
+ }
119
+
120
+ if (payload.event.type !== "response.create") {
121
+ throw new Error("msg.payload.event.type must be 'response.create'");
122
+ }
123
+
124
+ connection.send(payload.event);
125
+
126
+ return {
127
+ object: "response.websocket.client_event",
128
+ action: "send",
129
+ connection_id: connectionId,
130
+ event_type: payload.event.type,
131
+ };
132
+ }
133
+
134
+ async function closeResponsesWebSocket(parameters) {
135
+ const node = parameters._node;
136
+ const payload = parameters.payload || {};
137
+ const connectionId = requireConnectionId(payload);
138
+ const connections = getResponsesWebSocketConnections(node);
139
+ const connection = connections.get(connectionId);
140
+
141
+ if (!connection) {
142
+ throw new Error(
143
+ `Responses websocket connection '${connectionId}' is not open on this node`
144
+ );
145
+ }
146
+
147
+ const closeDetails = await connection.close({
148
+ code: payload.code,
149
+ reason: payload.reason,
150
+ });
151
+
152
+ connections.delete(connectionId);
153
+
154
+ return {
155
+ object: "response.websocket.connection",
156
+ action: "close",
157
+ connection_id: connectionId,
158
+ code: closeDetails.code,
159
+ reason: closeDetails.reason,
160
+ };
161
+ }
2
162
 
3
163
  async function streamResponse(parameters, response) {
4
164
  const { _node, msg } = parameters;
@@ -77,6 +237,30 @@ async function countInputTokens(parameters) {
77
237
  return response;
78
238
  }
79
239
 
240
+ async function manageModelResponseWebSocket(parameters) {
241
+ const payload = parameters.payload || {};
242
+
243
+ if (typeof payload.action !== "string" || payload.action.trim() === "") {
244
+ throw new Error(
245
+ "msg.payload.action must be one of 'connect', 'send', or 'close'"
246
+ );
247
+ }
248
+
249
+ if (payload.action === "connect") {
250
+ return connectResponsesWebSocket.call(this, parameters);
251
+ }
252
+
253
+ if (payload.action === "send") {
254
+ return sendResponsesWebSocketEvent.call(this, parameters);
255
+ }
256
+
257
+ if (payload.action === "close") {
258
+ return closeResponsesWebSocket.call(this, parameters);
259
+ }
260
+
261
+ throw new Error("msg.payload.action must be one of 'connect', 'send', or 'close'");
262
+ }
263
+
80
264
  module.exports = {
81
265
  createModelResponse,
82
266
  getModelResponse,
@@ -85,4 +269,5 @@ module.exports = {
85
269
  compactModelResponse,
86
270
  listInputItems,
87
271
  countInputTokens,
272
+ manageModelResponseWebSocket,
88
273
  };
@@ -34,4 +34,9 @@
34
34
  data-i18n="OpenaiApi.parameters.countInputTokens"
35
35
  ></option>
36
36
 
37
+ <option
38
+ value="manageModelResponseWebSocket"
39
+ data-i18n="OpenaiApi.parameters.manageModelResponseWebSocket"
40
+ ></option>
41
+
37
42
  </optgroup>
@@ -0,0 +1,150 @@
1
+ "use strict";
2
+
3
+ const EventEmitter = require("node:events");
4
+ const { WebSocket } = require("ws");
5
+
6
+ function buildResponsesWebSocketURL(clientParams) {
7
+ const baseURL = clientParams.baseURL || "https://api.openai.com/v1";
8
+ const path = "/responses";
9
+ const url = new URL(baseURL + (baseURL.endsWith("/") ? path.slice(1) : path));
10
+ const defaultQuery = clientParams.defaultQuery || {};
11
+
12
+ for (const [key, value] of Object.entries(defaultQuery)) {
13
+ if (value !== undefined && value !== null) {
14
+ url.searchParams.set(key, value);
15
+ }
16
+ }
17
+
18
+ if (url.protocol === "https:") {
19
+ url.protocol = "wss:";
20
+ } else if (url.protocol === "http:") {
21
+ url.protocol = "ws:";
22
+ }
23
+
24
+ return url;
25
+ }
26
+
27
+ function buildResponsesWebSocketHeaders(clientParams) {
28
+ const headers = {};
29
+ const defaultHeaders = clientParams.defaultHeaders || {};
30
+ const hasAuthorizationOverride = Object.prototype.hasOwnProperty.call(
31
+ defaultHeaders,
32
+ "Authorization"
33
+ );
34
+
35
+ for (const [headerName, headerValue] of Object.entries(defaultHeaders)) {
36
+ if (headerValue !== undefined && headerValue !== null) {
37
+ headers[headerName] = headerValue;
38
+ }
39
+ }
40
+
41
+ if (!clientParams.defaultQuery && !hasAuthorizationOverride) {
42
+ headers.Authorization = `Bearer ${clientParams.apiKey}`;
43
+ }
44
+
45
+ if (clientParams.organization) {
46
+ headers["OpenAI-Organization"] = clientParams.organization;
47
+ }
48
+
49
+ return headers;
50
+ }
51
+
52
+ function createEventParseError(error) {
53
+ const parseError = new Error("Could not parse Responses websocket event");
54
+ parseError.cause = error;
55
+ return parseError;
56
+ }
57
+
58
+ class ResponsesWebSocket extends EventEmitter {
59
+ constructor(clientParams) {
60
+ super();
61
+ this.clientParams = clientParams;
62
+ this.url = buildResponsesWebSocketURL(clientParams);
63
+ this.headers = buildResponsesWebSocketHeaders(clientParams);
64
+ this.socket = null;
65
+ }
66
+
67
+ async open() {
68
+ if (this.socket) {
69
+ throw new Error("Responses websocket connection is already open");
70
+ }
71
+
72
+ const socket = new WebSocket(this.url, { headers: this.headers });
73
+
74
+ await new Promise((resolve, reject) => {
75
+ socket.once("open", () => {
76
+ this.socket = socket;
77
+ this.attachSocketListeners(socket);
78
+ resolve();
79
+ });
80
+ socket.once("error", reject);
81
+ });
82
+
83
+ return {
84
+ url: this.url.toString(),
85
+ };
86
+ }
87
+
88
+ attachSocketListeners(socket) {
89
+ socket.on("message", (data) => {
90
+ let event;
91
+
92
+ try {
93
+ event = JSON.parse(data.toString());
94
+ } catch (error) {
95
+ this.emit("error", createEventParseError(error));
96
+ return;
97
+ }
98
+
99
+ this.emit("event", event);
100
+ });
101
+
102
+ socket.on("error", (error) => {
103
+ this.emit("error", error);
104
+ });
105
+
106
+ socket.on("close", (code, reason) => {
107
+ if (this.socket === socket) {
108
+ this.socket = null;
109
+ }
110
+
111
+ this.emit("close", {
112
+ code,
113
+ reason: reason.toString(),
114
+ });
115
+ });
116
+ }
117
+
118
+ send(event) {
119
+ if (!this.socket) {
120
+ throw new Error("Responses websocket connection is not open");
121
+ }
122
+
123
+ this.socket.send(JSON.stringify(event));
124
+ }
125
+
126
+ async close(props = {}) {
127
+ if (!this.socket) {
128
+ throw new Error("Responses websocket connection is not open");
129
+ }
130
+
131
+ const socket = this.socket;
132
+ const closeCode = props.code ?? 1000;
133
+ const closeReason = props.reason ?? "OK";
134
+
135
+ await new Promise((resolve, reject) => {
136
+ socket.once("close", resolve);
137
+ socket.once("error", reject);
138
+ socket.close(closeCode, closeReason);
139
+ });
140
+
141
+ return {
142
+ code: closeCode,
143
+ reason: closeReason,
144
+ };
145
+ }
146
+ }
147
+
148
+ module.exports = {
149
+ ResponsesWebSocket,
150
+ };