@inductiv/node-red-openai-api 1.103.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.
- package/README.md +213 -86
- package/examples/realtime/client-secrets.json +182 -0
- package/examples/responses/computer-use.json +142 -0
- package/examples/responses/mcp.json +1 -1
- package/examples/responses/phase.json +102 -0
- package/examples/responses/tool-search.json +107 -0
- package/examples/responses/websocket.json +172 -0
- package/internals/openai-api-features-v6.23.0-v6.27.0.md +96 -0
- package/lib.js +12696 -15003
- package/locales/en-US/node.json +50 -1
- package/node.html +1723 -1012
- package/node.js +204 -54
- package/package.json +9 -7
- package/src/assistants/help.html +1 -77
- package/src/audio/help.html +1 -37
- package/src/batch/help.html +3 -17
- package/src/chat/help.html +11 -89
- package/src/container-files/help.html +1 -27
- package/src/containers/help.html +8 -18
- package/src/conversations/help.html +135 -0
- package/src/conversations/methods.js +73 -0
- package/src/conversations/template.html +10 -0
- package/src/embeddings/help.html +1 -11
- package/src/evals/help.html +249 -0
- package/src/evals/methods.js +114 -0
- package/src/evals/template.html +14 -0
- package/src/files/help.html +4 -17
- package/src/fine-tuning/help.html +1 -35
- package/src/images/help.html +1 -45
- package/src/lib.js +53 -1
- package/src/messages/help.html +19 -39
- package/src/messages/methods.js +13 -0
- package/src/messages/template.html +7 -18
- package/src/models/help.html +1 -5
- package/src/moderations/help.html +1 -5
- package/src/node.html +126 -37
- package/src/realtime/help.html +209 -0
- package/src/realtime/methods.js +45 -0
- package/src/realtime/template.html +7 -0
- package/src/responses/help.html +286 -63
- package/src/responses/methods.js +234 -16
- package/src/responses/template.html +21 -1
- package/src/responses/websocket.js +150 -0
- package/src/runs/help.html +1 -123
- package/src/skills/help.html +183 -0
- package/src/skills/methods.js +99 -0
- package/src/skills/template.html +13 -0
- package/src/threads/help.html +1 -15
- package/src/uploads/help.html +1 -21
- package/src/vector-store-file-batches/help.html +1 -27
- package/src/vector-store-file-batches/methods.js +5 -5
- package/src/vector-store-files/help.html +1 -25
- package/src/vector-store-files/methods.js +4 -7
- package/src/vector-stores/help.html +2 -31
- package/src/vector-stores/methods.js +5 -11
- package/src/vector-stores/template.html +7 -22
- package/src/videos/help.html +113 -0
- package/src/videos/methods.js +50 -0
- package/src/videos/template.html +8 -0
- package/src/webhooks/help.html +61 -0
- package/src/webhooks/methods.js +40 -0
- package/src/webhooks/template.html +4 -0
- package/test/openai-methods-mapping.test.js +1559 -0
- package/test/openai-node-auth-routing.test.js +206 -0
- package/test/openai-responses-websocket.test.js +472 -0
- package/test/service-host-editor-template.test.js +56 -0
- package/test/service-host-node.test.js +185 -0
- package/test/services.test.js +150 -0
- package/test/utils.test.js +78 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// This file keeps the Service Host auth rules honest.
|
|
4
|
+
// It checks that header auth, query auth, and default Authorization behavior are all routed the way this node promises.
|
|
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 createEvaluateNodeProperty() {
|
|
14
|
+
return (value, type, node, msg, callback) => {
|
|
15
|
+
let resolvedValue = value;
|
|
16
|
+
|
|
17
|
+
if (type === "env" || type === "msg" || type === "flow" || type === "global") {
|
|
18
|
+
resolvedValue = `resolved:${type}:${value}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (typeof callback === "function") {
|
|
22
|
+
callback(null, resolvedValue);
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return resolvedValue;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createNodeHarness() {
|
|
31
|
+
const registeredTypes = {};
|
|
32
|
+
const configNodes = new Map();
|
|
33
|
+
|
|
34
|
+
const RED = {
|
|
35
|
+
nodes: {
|
|
36
|
+
createNode: (node, config) => {
|
|
37
|
+
const emitter = new EventEmitter();
|
|
38
|
+
node.on = emitter.on.bind(emitter);
|
|
39
|
+
node.emit = emitter.emit.bind(emitter);
|
|
40
|
+
|
|
41
|
+
node.sentMessages = [];
|
|
42
|
+
node.errorMessages = [];
|
|
43
|
+
node.send = (msg) => {
|
|
44
|
+
node.sentMessages.push(msg);
|
|
45
|
+
};
|
|
46
|
+
node.error = (error, msg) => {
|
|
47
|
+
node.errorMessages.push({ error, msg });
|
|
48
|
+
};
|
|
49
|
+
node.status = () => { };
|
|
50
|
+
node.context = () => ({
|
|
51
|
+
flow: { get: () => undefined },
|
|
52
|
+
global: { get: () => undefined },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
node.credentials = config.credentials || {};
|
|
56
|
+
node.id = config.id;
|
|
57
|
+
},
|
|
58
|
+
registerType: (name, ctor) => {
|
|
59
|
+
registeredTypes[name] = ctor;
|
|
60
|
+
},
|
|
61
|
+
getNode: (id) => configNodes.get(id),
|
|
62
|
+
},
|
|
63
|
+
util: {
|
|
64
|
+
evaluateNodeProperty: createEvaluateNodeProperty(),
|
|
65
|
+
getMessageProperty: (msg, path) => {
|
|
66
|
+
if (path === "payload") {
|
|
67
|
+
return msg.payload;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return path.split(".").reduce((value, key) => {
|
|
71
|
+
if (value === undefined || value === null) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
return value[key];
|
|
75
|
+
}, msg);
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
nodeModule(RED);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
OpenaiApiNode: registeredTypes["OpenAI API"],
|
|
84
|
+
ServiceHostNode: registeredTypes["Service Host"],
|
|
85
|
+
configNodes,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function runAuthRoutingCase(serviceConfig) {
|
|
90
|
+
const harness = createNodeHarness();
|
|
91
|
+
assert.ok(harness.OpenaiApiNode, "OpenAI API node should register");
|
|
92
|
+
assert.ok(harness.ServiceHostNode, "Service Host node should register");
|
|
93
|
+
|
|
94
|
+
const serviceNode = new harness.ServiceHostNode({
|
|
95
|
+
id: "service-1",
|
|
96
|
+
apiBase: "https://api.example.com/v1",
|
|
97
|
+
apiBaseType: "str",
|
|
98
|
+
secureApiKeyHeaderOrQueryName: "Authorization",
|
|
99
|
+
secureApiKeyHeaderOrQueryNameType: "str",
|
|
100
|
+
organizationId: "OPENAI_ORG_ID",
|
|
101
|
+
organizationIdType: "env",
|
|
102
|
+
secureApiKeyIsQuery: false,
|
|
103
|
+
secureApiKeyValueType: "cred",
|
|
104
|
+
credentials: {
|
|
105
|
+
secureApiKeyValue: "sk-test",
|
|
106
|
+
},
|
|
107
|
+
...serviceConfig,
|
|
108
|
+
});
|
|
109
|
+
harness.configNodes.set("service-1", serviceNode);
|
|
110
|
+
|
|
111
|
+
const apiNode = new harness.OpenaiApiNode({
|
|
112
|
+
id: "openai-1",
|
|
113
|
+
service: "service-1",
|
|
114
|
+
method: "createModelResponse",
|
|
115
|
+
property: "payload",
|
|
116
|
+
propertyType: "msg",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
apiNode.emit("input", { payload: { model: "gpt-5-nano" } });
|
|
120
|
+
|
|
121
|
+
// Allow Promise-based input handler to resolve and send.
|
|
122
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
123
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
124
|
+
|
|
125
|
+
assert.equal(apiNode.errorMessages.length, 0);
|
|
126
|
+
assert.equal(apiNode.sentMessages.length, 1);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
test("applies custom auth header to OpenAI client params", async () => {
|
|
130
|
+
const originalCreateModelResponse = OpenaiApi.prototype.createModelResponse;
|
|
131
|
+
const capturedClientParams = [];
|
|
132
|
+
|
|
133
|
+
OpenaiApi.prototype.createModelResponse = async function () {
|
|
134
|
+
capturedClientParams.push({ ...this.clientParams });
|
|
135
|
+
return { ok: true };
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await runAuthRoutingCase({
|
|
140
|
+
secureApiKeyHeaderOrQueryName: "X-API-Key",
|
|
141
|
+
secureApiKeyHeaderOrQueryNameType: "str",
|
|
142
|
+
secureApiKeyIsQuery: false,
|
|
143
|
+
});
|
|
144
|
+
} finally {
|
|
145
|
+
OpenaiApi.prototype.createModelResponse = originalCreateModelResponse;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
assert.equal(capturedClientParams.length, 1);
|
|
149
|
+
assert.deepEqual(capturedClientParams[0].defaultHeaders, {
|
|
150
|
+
Authorization: null,
|
|
151
|
+
"X-API-Key": "sk-test",
|
|
152
|
+
});
|
|
153
|
+
assert.equal(capturedClientParams[0].defaultQuery, undefined);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("applies query-string auth mode with typed header name", async () => {
|
|
157
|
+
const originalCreateModelResponse = OpenaiApi.prototype.createModelResponse;
|
|
158
|
+
const capturedClientParams = [];
|
|
159
|
+
|
|
160
|
+
OpenaiApi.prototype.createModelResponse = async function () {
|
|
161
|
+
capturedClientParams.push({ ...this.clientParams });
|
|
162
|
+
return { ok: true };
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
await runAuthRoutingCase({
|
|
167
|
+
secureApiKeyHeaderOrQueryName: "OPENAI_AUTH_QUERY_PARAM",
|
|
168
|
+
secureApiKeyHeaderOrQueryNameType: "env",
|
|
169
|
+
secureApiKeyIsQuery: "true",
|
|
170
|
+
});
|
|
171
|
+
} finally {
|
|
172
|
+
OpenaiApi.prototype.createModelResponse = originalCreateModelResponse;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
assert.equal(capturedClientParams.length, 1);
|
|
176
|
+
assert.deepEqual(capturedClientParams[0].defaultHeaders, {
|
|
177
|
+
Authorization: null,
|
|
178
|
+
});
|
|
179
|
+
assert.deepEqual(capturedClientParams[0].defaultQuery, {
|
|
180
|
+
"resolved:env:OPENAI_AUTH_QUERY_PARAM": "sk-test",
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("keeps OpenAI SDK default Authorization behavior when header stays Authorization", async () => {
|
|
185
|
+
const originalCreateModelResponse = OpenaiApi.prototype.createModelResponse;
|
|
186
|
+
const capturedClientParams = [];
|
|
187
|
+
|
|
188
|
+
OpenaiApi.prototype.createModelResponse = async function () {
|
|
189
|
+
capturedClientParams.push({ ...this.clientParams });
|
|
190
|
+
return { ok: true };
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
await runAuthRoutingCase({
|
|
195
|
+
secureApiKeyHeaderOrQueryName: "Authorization",
|
|
196
|
+
secureApiKeyHeaderOrQueryNameType: "str",
|
|
197
|
+
secureApiKeyIsQuery: false,
|
|
198
|
+
});
|
|
199
|
+
} finally {
|
|
200
|
+
OpenaiApi.prototype.createModelResponse = originalCreateModelResponse;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
assert.equal(capturedClientParams.length, 1);
|
|
204
|
+
assert.equal(capturedClientParams[0].defaultHeaders, undefined);
|
|
205
|
+
assert.equal(capturedClientParams[0].defaultQuery, undefined);
|
|
206
|
+
});
|
|
@@ -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
|
+
});
|