modelmix 5.1.12 → 5.1.15
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 +25 -5
- package/http-client.js +6 -6
- package/index.d.ts +10 -6
- package/index.js +133 -73
- package/lib/abort-signal.js +57 -0
- package/lib/providers/anthropic.js +2 -2
- package/lib/providers/base.js +17 -6
- package/lib/providers/google.js +9 -2
- package/lib/providers/openai-compatible.js +8 -8
- package/lib/providers/openai.js +32 -10
- package/mcp-tools.js +5 -2
- package/package.json +2 -2
- package/plugins/rlm/index.d.ts +1 -0
- package/plugins/rlm/lib/isolated-vm-sandbox.js +8 -1
- package/plugins/rlm/lib/plugin.js +23 -5
- package/plugins/rlm/test/isolated-vm-sandbox.test.js +23 -0
- package/skills/modelmix/SKILL.md +24 -6
- package/test/abort.test.js +517 -0
- package/test/live.test.js +9 -4
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
const { expect } = require('chai');
|
|
2
|
+
const { EventEmitter } = require('events');
|
|
3
|
+
const sinon = require('sinon');
|
|
4
|
+
const {
|
|
5
|
+
MixCustom,
|
|
6
|
+
MixModeration,
|
|
7
|
+
MixOpenAI,
|
|
8
|
+
ModelMix
|
|
9
|
+
} = require('../index.js');
|
|
10
|
+
const createOpenAIProviders = require('../lib/providers/openai');
|
|
11
|
+
const { rejectsAnthropicSamplingParams } = require('../lib/providers/anthropic');
|
|
12
|
+
const {
|
|
13
|
+
fetchBinaryResponse,
|
|
14
|
+
fetchJsonResponse,
|
|
15
|
+
fetchStreamResponse
|
|
16
|
+
} = require('../http-client');
|
|
17
|
+
|
|
18
|
+
function deferred() {
|
|
19
|
+
let resolve;
|
|
20
|
+
let reject;
|
|
21
|
+
const promise = new Promise((resolvePromise, rejectPromise) => {
|
|
22
|
+
resolve = resolvePromise;
|
|
23
|
+
reject = rejectPromise;
|
|
24
|
+
});
|
|
25
|
+
return { promise, reject, resolve };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function rejection(promise) {
|
|
29
|
+
try {
|
|
30
|
+
await promise;
|
|
31
|
+
} catch (error) {
|
|
32
|
+
return error;
|
|
33
|
+
}
|
|
34
|
+
throw new Error('Expected promise to reject.');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createProvider(handler) {
|
|
38
|
+
const provider = new MixCustom();
|
|
39
|
+
provider.create = handler;
|
|
40
|
+
return provider;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createModel(handler, config = {}) {
|
|
44
|
+
return ModelMix.new({
|
|
45
|
+
config: {
|
|
46
|
+
bottleneck: { maxConcurrent: 8, minTime: 0 },
|
|
47
|
+
...config
|
|
48
|
+
}
|
|
49
|
+
}).attach('custom', createProvider(handler)).addText('test');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('AbortSignal execution contract', () => {
|
|
53
|
+
if (global.setupTestHooks) global.setupTestHooks();
|
|
54
|
+
|
|
55
|
+
it('passes the direct signal through every terminal method without storing it', async () => {
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
const cases = [
|
|
58
|
+
['message', model => model.message(controller.signal), 'plain'],
|
|
59
|
+
['raw', model => model.raw(controller.signal), 'plain'],
|
|
60
|
+
['stream', model => model.stream(() => {}, controller.signal), 'plain'],
|
|
61
|
+
['block', model => model.block({}, controller.signal), '```plain```'],
|
|
62
|
+
['json', model => model.json(null, {}, {}, controller.signal), '{"ok":true}'],
|
|
63
|
+
['execute', model => model.execute({ signal: controller.signal }), 'plain']
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
for (const [, invoke, message] of cases) {
|
|
67
|
+
let providerRequest;
|
|
68
|
+
const model = createModel(async request => {
|
|
69
|
+
providerRequest = request;
|
|
70
|
+
return { message, toolCalls: [] };
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await invoke(model);
|
|
74
|
+
|
|
75
|
+
expect(providerRequest.signal).to.equal(controller.signal);
|
|
76
|
+
expect(providerRequest.config).to.not.have.property('signal');
|
|
77
|
+
expect(providerRequest.options).to.not.have.property('signal');
|
|
78
|
+
expect(model).to.not.have.property('signal');
|
|
79
|
+
expect(model.config).to.not.have.property('signal');
|
|
80
|
+
expect(model.options).to.not.have.property('signal');
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('rejects invalid and misplaced signals before provider work starts', async () => {
|
|
85
|
+
const signal = new AbortController().signal;
|
|
86
|
+
expect(() => ModelMix.new({ config: { signal } })).to.throw(TypeError, 'config.signal');
|
|
87
|
+
expect(() => ModelMix.new({ options: { signal } })).to.throw(TypeError, 'options.signal');
|
|
88
|
+
expect(() => new MixCustom({ config: { signal } })).to.throw(TypeError, 'config.signal');
|
|
89
|
+
expect(() => new MixCustom({ options: { signal } })).to.throw(TypeError, 'options.signal');
|
|
90
|
+
|
|
91
|
+
let providerCalls = 0;
|
|
92
|
+
const model = createModel(async () => {
|
|
93
|
+
providerCalls += 1;
|
|
94
|
+
return { message: 'unexpected', toolCalls: [] };
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(await rejection(model.message({}))).to.be.instanceOf(TypeError);
|
|
98
|
+
expect(await rejection(model.execute({ config: { signal } }))).to.be.instanceOf(TypeError);
|
|
99
|
+
expect(await rejection(model.execute({ options: { signal } }))).to.be.instanceOf(TypeError);
|
|
100
|
+
expect(providerCalls).to.equal(0);
|
|
101
|
+
|
|
102
|
+
const pluginModel = createModel(async () => {
|
|
103
|
+
providerCalls += 1;
|
|
104
|
+
return { message: 'unexpected', toolCalls: [] };
|
|
105
|
+
}).use({
|
|
106
|
+
name: 'misplaced-signal',
|
|
107
|
+
execute(context, next) {
|
|
108
|
+
context.request.options.signal = signal;
|
|
109
|
+
return next();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
const pluginError = await rejection(pluginModel.message());
|
|
113
|
+
expect(pluginError).to.be.instanceOf(TypeError);
|
|
114
|
+
expect(pluginError.message).to.include('options.signal');
|
|
115
|
+
expect(providerCalls).to.equal(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('preserves a pre-aborted custom reason and leaves execution state untouched', async () => {
|
|
119
|
+
const reason = new Error('cancel before start');
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
controller.abort(reason);
|
|
122
|
+
let providerCalls = 0;
|
|
123
|
+
const model = createModel(async () => {
|
|
124
|
+
providerCalls += 1;
|
|
125
|
+
return { message: 'unexpected', toolCalls: [] };
|
|
126
|
+
});
|
|
127
|
+
const originalMessages = model.messages.map(message => ({ ...message }));
|
|
128
|
+
|
|
129
|
+
expect(await rejection(model.message(controller.signal))).to.equal(reason);
|
|
130
|
+
expect(providerCalls).to.equal(0);
|
|
131
|
+
expect(model.lastRaw).to.equal(null);
|
|
132
|
+
expect(model.messages).to.deep.equal(originalMessages);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('aborts remote image preparation without mutating messages or invoking a provider', async () => {
|
|
136
|
+
const reason = new Error('stop image download');
|
|
137
|
+
const controller = new AbortController();
|
|
138
|
+
const fetchStarted = deferred();
|
|
139
|
+
let fetchedSignal;
|
|
140
|
+
sinon.stub(global, 'fetch').callsFake((_url, { signal }) => {
|
|
141
|
+
fetchedSignal = signal;
|
|
142
|
+
fetchStarted.resolve();
|
|
143
|
+
return new Promise((_, rejectPromise) => {
|
|
144
|
+
signal.addEventListener('abort', () => rejectPromise(signal.reason), { once: true });
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
let providerCalls = 0;
|
|
148
|
+
const model = ModelMix.new({ config: { bottleneck: { maxConcurrent: 1, minTime: 0 } } })
|
|
149
|
+
.attach('custom', createProvider(async () => {
|
|
150
|
+
providerCalls += 1;
|
|
151
|
+
return { message: 'unexpected', toolCalls: [] };
|
|
152
|
+
}))
|
|
153
|
+
.addImageFromUrl('https://example.test/image.png')
|
|
154
|
+
.addText('describe');
|
|
155
|
+
const originalMessages = JSON.parse(JSON.stringify(model.messages));
|
|
156
|
+
|
|
157
|
+
const execution = model.raw(controller.signal);
|
|
158
|
+
await fetchStarted.promise;
|
|
159
|
+
controller.abort(reason);
|
|
160
|
+
|
|
161
|
+
expect(await rejection(execution)).to.equal(reason);
|
|
162
|
+
expect(fetchedSignal).to.equal(controller.signal);
|
|
163
|
+
expect(providerCalls).to.equal(0);
|
|
164
|
+
expect(model.messages).to.deep.equal(originalMessages);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('aborts an in-flight custom provider without falling back or recording its late result', async () => {
|
|
168
|
+
const pending = deferred();
|
|
169
|
+
const primaryStarted = deferred();
|
|
170
|
+
const reason = new Error('stop provider');
|
|
171
|
+
const controller = new AbortController();
|
|
172
|
+
let primaryCalls = 0;
|
|
173
|
+
let fallbackCalls = 0;
|
|
174
|
+
const model = ModelMix.new({ config: { bottleneck: { maxConcurrent: 2, minTime: 0 } } })
|
|
175
|
+
.attach('primary', createProvider(async () => {
|
|
176
|
+
primaryCalls += 1;
|
|
177
|
+
primaryStarted.resolve();
|
|
178
|
+
return pending.promise;
|
|
179
|
+
}))
|
|
180
|
+
.attach('fallback', createProvider(async () => {
|
|
181
|
+
fallbackCalls += 1;
|
|
182
|
+
return { message: 'fallback', toolCalls: [] };
|
|
183
|
+
}))
|
|
184
|
+
.addText('test');
|
|
185
|
+
|
|
186
|
+
const execution = model.raw(controller.signal);
|
|
187
|
+
await primaryStarted.promise;
|
|
188
|
+
controller.abort(reason);
|
|
189
|
+
|
|
190
|
+
expect(await rejection(execution)).to.equal(reason);
|
|
191
|
+
pending.resolve({ message: 'late', toolCalls: [] });
|
|
192
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
193
|
+
expect(primaryCalls).to.equal(1);
|
|
194
|
+
expect(fallbackCalls).to.equal(0);
|
|
195
|
+
expect(model.lastRaw).to.equal(null);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('rejects while queued and prevents the queued provider invocation', async () => {
|
|
199
|
+
const first = deferred();
|
|
200
|
+
const firstStarted = deferred();
|
|
201
|
+
const reason = new Error('leave queue');
|
|
202
|
+
const controller = new AbortController();
|
|
203
|
+
let providerCalls = 0;
|
|
204
|
+
const model = createModel(async () => {
|
|
205
|
+
providerCalls += 1;
|
|
206
|
+
if (providerCalls === 1) {
|
|
207
|
+
firstStarted.resolve();
|
|
208
|
+
return first.promise;
|
|
209
|
+
}
|
|
210
|
+
return { message: 'unexpected', toolCalls: [] };
|
|
211
|
+
}, {
|
|
212
|
+
max_history: -1,
|
|
213
|
+
bottleneck: { maxConcurrent: 1, minTime: 0 }
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const running = model.raw();
|
|
217
|
+
await firstStarted.promise;
|
|
218
|
+
const queued = model.raw(controller.signal);
|
|
219
|
+
controller.abort(reason);
|
|
220
|
+
|
|
221
|
+
expect(await rejection(queued)).to.equal(reason);
|
|
222
|
+
first.resolve({ message: 'first', toolCalls: [] });
|
|
223
|
+
await running;
|
|
224
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
225
|
+
expect(providerCalls).to.equal(1);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('aborts retry backoff without retrying or falling back', async () => {
|
|
229
|
+
const reason = new Error('stop retry');
|
|
230
|
+
const controller = new AbortController();
|
|
231
|
+
let primaryCalls = 0;
|
|
232
|
+
let fallbackCalls = 0;
|
|
233
|
+
const model = ModelMix.new({
|
|
234
|
+
config: {
|
|
235
|
+
bottleneck: { maxConcurrent: 1, minTime: 0 },
|
|
236
|
+
retry: {
|
|
237
|
+
enabled: true,
|
|
238
|
+
retries: 2,
|
|
239
|
+
baseDelayMs: 200,
|
|
240
|
+
maxDelayMs: 200,
|
|
241
|
+
retryableStatusCodes: [429]
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
.attach('primary', createProvider(async () => {
|
|
246
|
+
primaryCalls += 1;
|
|
247
|
+
throw { message: 'rate limited', statusCode: 429 };
|
|
248
|
+
}))
|
|
249
|
+
.attach('fallback', createProvider(async () => {
|
|
250
|
+
fallbackCalls += 1;
|
|
251
|
+
return { message: 'fallback', toolCalls: [] };
|
|
252
|
+
}))
|
|
253
|
+
.addText('test');
|
|
254
|
+
|
|
255
|
+
const execution = model.raw(controller.signal);
|
|
256
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
257
|
+
controller.abort(reason);
|
|
258
|
+
|
|
259
|
+
expect(await rejection(execution)).to.equal(reason);
|
|
260
|
+
expect(primaryCalls).to.equal(1);
|
|
261
|
+
expect(fallbackCalls).to.equal(0);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('isolates signals across concurrent executions on one instance', async () => {
|
|
265
|
+
const first = deferred();
|
|
266
|
+
const second = deferred();
|
|
267
|
+
const started = deferred();
|
|
268
|
+
const controllerA = new AbortController();
|
|
269
|
+
const controllerB = new AbortController();
|
|
270
|
+
const reason = new Error('only A');
|
|
271
|
+
const requests = [];
|
|
272
|
+
const model = createModel(async request => {
|
|
273
|
+
requests.push(request);
|
|
274
|
+
if (requests.length === 2) started.resolve();
|
|
275
|
+
return requests.length === 1 ? first.promise : second.promise;
|
|
276
|
+
}, {
|
|
277
|
+
max_history: -1,
|
|
278
|
+
bottleneck: { maxConcurrent: 2, minTime: 0 }
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const executionA = model.raw(controllerA.signal);
|
|
282
|
+
const executionB = model.raw(controllerB.signal);
|
|
283
|
+
await started.promise;
|
|
284
|
+
controllerA.abort(reason);
|
|
285
|
+
second.resolve({ message: 'B', toolCalls: [] });
|
|
286
|
+
|
|
287
|
+
expect(await rejection(executionA)).to.equal(reason);
|
|
288
|
+
expect((await executionB).message).to.equal('B');
|
|
289
|
+
first.resolve({ message: 'late A', toolCalls: [] });
|
|
290
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
291
|
+
expect(requests.map(request => request.signal)).to.deep.equal([
|
|
292
|
+
controllerA.signal,
|
|
293
|
+
controllerB.signal
|
|
294
|
+
]);
|
|
295
|
+
expect(model.lastRaw.message).to.equal('B');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('exposes the signal to plugins and inherits it in child invocations', async () => {
|
|
299
|
+
const controller = new AbortController();
|
|
300
|
+
const contexts = [];
|
|
301
|
+
let providerSignal;
|
|
302
|
+
const provider = createProvider(async request => {
|
|
303
|
+
providerSignal = request.signal;
|
|
304
|
+
return { message: 'child', toolCalls: [] };
|
|
305
|
+
});
|
|
306
|
+
const model = ModelMix.new({ config: { bottleneck: { maxConcurrent: 2, minTime: 0 } } })
|
|
307
|
+
.attach('custom', provider)
|
|
308
|
+
.use({
|
|
309
|
+
name: 'child',
|
|
310
|
+
execute(context, next) {
|
|
311
|
+
contexts.push(context);
|
|
312
|
+
if (context.execution.depth > 0) return next();
|
|
313
|
+
return context.invoke({
|
|
314
|
+
messages: [{ role: 'user', content: 'child prompt' }],
|
|
315
|
+
history: false
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
.addText('parent prompt');
|
|
320
|
+
|
|
321
|
+
expect((await model.raw(controller.signal)).message).to.equal('child');
|
|
322
|
+
expect(contexts).to.have.length(2);
|
|
323
|
+
expect(contexts.every(context => context.signal === controller.signal)).to.equal(true);
|
|
324
|
+
expect(contexts.every(context => !Object.hasOwn(context.request.config, 'signal'))).to.equal(true);
|
|
325
|
+
expect(contexts.every(context => !Object.hasOwn(context.request.options, 'signal'))).to.equal(true);
|
|
326
|
+
expect(providerSignal).to.equal(controller.signal);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('passes the signal to local and MCP tools and preserves abort through tool handling', async () => {
|
|
330
|
+
const controller = new AbortController();
|
|
331
|
+
let localSignal;
|
|
332
|
+
const model = ModelMix.new().addTool({
|
|
333
|
+
name: 'local',
|
|
334
|
+
description: 'local tool',
|
|
335
|
+
inputSchema: { type: 'object' }
|
|
336
|
+
}, async (_args, signal) => {
|
|
337
|
+
localSignal = signal;
|
|
338
|
+
return 'local result';
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const localResult = await model.processToolCalls([
|
|
342
|
+
{ id: 'local-1', name: 'local', input: {} }
|
|
343
|
+
], controller.signal);
|
|
344
|
+
expect(localSignal).to.equal(controller.signal);
|
|
345
|
+
expect(localResult[0].content).to.equal('local result');
|
|
346
|
+
|
|
347
|
+
let mcpArguments;
|
|
348
|
+
model.toolClient.remote = {
|
|
349
|
+
async callTool(...args) {
|
|
350
|
+
mcpArguments = args;
|
|
351
|
+
return { content: [{ type: 'text', text: 'remote result' }] };
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
const remoteResult = await model.processToolCalls([
|
|
355
|
+
{ id: 'remote-1', name: 'remote', input: { value: 1 } }
|
|
356
|
+
], controller.signal);
|
|
357
|
+
expect(remoteResult[0].content).to.equal('remote result');
|
|
358
|
+
expect(mcpArguments[2]).to.deep.equal({ signal: controller.signal });
|
|
359
|
+
|
|
360
|
+
const reason = new Error('stop local tool');
|
|
361
|
+
const abortController = new AbortController();
|
|
362
|
+
const toolStarted = deferred();
|
|
363
|
+
let providerCalls = 0;
|
|
364
|
+
const toolModel = createModel(async () => {
|
|
365
|
+
providerCalls += 1;
|
|
366
|
+
return {
|
|
367
|
+
message: '',
|
|
368
|
+
toolCalls: [{ id: 'slow-1', name: 'slow', input: {} }]
|
|
369
|
+
};
|
|
370
|
+
}, { max_history: -1 }).addTool({
|
|
371
|
+
name: 'slow',
|
|
372
|
+
description: 'slow tool',
|
|
373
|
+
inputSchema: { type: 'object' }
|
|
374
|
+
}, async (_args, signal) => {
|
|
375
|
+
toolStarted.resolve(signal);
|
|
376
|
+
return new Promise((_, rejectPromise) => {
|
|
377
|
+
signal.addEventListener('abort', () => rejectPromise(signal.reason), { once: true });
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
const originalMessages = JSON.parse(JSON.stringify(toolModel.messages));
|
|
381
|
+
const execution = toolModel.raw(abortController.signal);
|
|
382
|
+
expect(await toolStarted.promise).to.equal(abortController.signal);
|
|
383
|
+
abortController.abort(reason);
|
|
384
|
+
|
|
385
|
+
expect(await rejection(execution)).to.equal(reason);
|
|
386
|
+
expect(providerCalls).to.equal(1);
|
|
387
|
+
expect(toolModel.messages).to.deep.equal(originalMessages);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('forwards the signal through every fetch helper without serializing it', async () => {
|
|
391
|
+
const controller = new AbortController();
|
|
392
|
+
const calls = [];
|
|
393
|
+
sinon.stub(global, 'fetch').callsFake(async (_url, init) => {
|
|
394
|
+
calls.push(init);
|
|
395
|
+
if (calls.length === 1) {
|
|
396
|
+
return {
|
|
397
|
+
ok: true,
|
|
398
|
+
status: 200,
|
|
399
|
+
headers: new Headers(),
|
|
400
|
+
text: async () => '{"ok":true}'
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
if (calls.length === 2) {
|
|
404
|
+
return {
|
|
405
|
+
ok: true,
|
|
406
|
+
status: 200,
|
|
407
|
+
headers: new Headers(),
|
|
408
|
+
arrayBuffer: async () => Uint8Array.from([1, 2, 3]).buffer
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
return {
|
|
412
|
+
ok: true,
|
|
413
|
+
status: 200,
|
|
414
|
+
headers: new Headers(),
|
|
415
|
+
body: new ReadableStream({
|
|
416
|
+
start(streamController) {
|
|
417
|
+
streamController.close();
|
|
418
|
+
}
|
|
419
|
+
})
|
|
420
|
+
};
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
await fetchJsonResponse('https://example.test/json', { body: '{}', signal: controller.signal });
|
|
424
|
+
await fetchBinaryResponse('https://example.test/binary', { signal: controller.signal });
|
|
425
|
+
const stream = await fetchStreamResponse('https://example.test/stream', { body: '{}', signal: controller.signal });
|
|
426
|
+
stream.data.resume();
|
|
427
|
+
|
|
428
|
+
expect(calls).to.have.length(3);
|
|
429
|
+
expect(calls.every(call => call.signal === controller.signal)).to.equal(true);
|
|
430
|
+
expect(JSON.parse(calls[0].body)).to.not.have.property('signal');
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('keeps the signal outside a built-in provider payload', async () => {
|
|
434
|
+
const controller = new AbortController();
|
|
435
|
+
const reason = new Error('stop OpenAI request');
|
|
436
|
+
const fetchStarted = deferred();
|
|
437
|
+
let request;
|
|
438
|
+
sinon.stub(global, 'fetch').callsFake((_url, init) => {
|
|
439
|
+
request = init;
|
|
440
|
+
fetchStarted.resolve();
|
|
441
|
+
return new Promise((_, rejectPromise) => {
|
|
442
|
+
init.signal.addEventListener('abort', () => rejectPromise(init.signal.reason), { once: true });
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
const model = ModelMix.new({ config: { bottleneck: { maxConcurrent: 1, minTime: 0 } } })
|
|
446
|
+
.gpt51()
|
|
447
|
+
.addText('test');
|
|
448
|
+
|
|
449
|
+
const execution = model.raw(controller.signal);
|
|
450
|
+
await fetchStarted.promise;
|
|
451
|
+
expect(request.signal).to.equal(controller.signal);
|
|
452
|
+
expect(JSON.parse(request.body)).to.not.have.property('signal');
|
|
453
|
+
controller.abort(reason);
|
|
454
|
+
expect(await rejection(execution)).to.equal(reason);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('aborts the realtime WebSocket and preserves the custom reason', async () => {
|
|
458
|
+
const created = deferred();
|
|
459
|
+
class FakeWebSocket extends EventEmitter {
|
|
460
|
+
constructor() {
|
|
461
|
+
super();
|
|
462
|
+
this.readyState = 0;
|
|
463
|
+
this.terminated = false;
|
|
464
|
+
created.resolve(this);
|
|
465
|
+
setImmediate(() => {
|
|
466
|
+
if (this.terminated) return;
|
|
467
|
+
this.readyState = 1;
|
|
468
|
+
this.emit('open');
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
send() {}
|
|
473
|
+
|
|
474
|
+
close() {
|
|
475
|
+
this.terminate();
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
terminate() {
|
|
479
|
+
if (this.terminated) return;
|
|
480
|
+
this.terminated = true;
|
|
481
|
+
this.readyState = 3;
|
|
482
|
+
this.emit('close');
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const wsPath = require.resolve('ws');
|
|
486
|
+
const originalWebSocket = require.cache[wsPath].exports;
|
|
487
|
+
require.cache[wsPath].exports = FakeWebSocket;
|
|
488
|
+
try {
|
|
489
|
+
const { MixOpenAIWebSocket } = createOpenAIProviders({
|
|
490
|
+
ModelMix,
|
|
491
|
+
MixCustom,
|
|
492
|
+
MixOpenAI,
|
|
493
|
+
MixModeration,
|
|
494
|
+
rejectsAnthropicSamplingParams
|
|
495
|
+
});
|
|
496
|
+
const provider = new MixOpenAIWebSocket({
|
|
497
|
+
config: {
|
|
498
|
+
apiKey: 'test-key',
|
|
499
|
+
realtimeUrl: 'ws://example.test',
|
|
500
|
+
websocketTimeoutMs: 2000
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
const model = ModelMix.new({ config: { bottleneck: { maxConcurrent: 1, minTime: 0 } } })
|
|
504
|
+
.attach('realtime-test', provider)
|
|
505
|
+
.addText('test');
|
|
506
|
+
const controller = new AbortController();
|
|
507
|
+
const reason = new Error('close realtime');
|
|
508
|
+
const execution = model.raw(controller.signal);
|
|
509
|
+
const socket = await created.promise;
|
|
510
|
+
controller.abort(reason);
|
|
511
|
+
expect(await rejection(execution)).to.equal(reason);
|
|
512
|
+
expect(socket.terminated).to.equal(true);
|
|
513
|
+
} finally {
|
|
514
|
+
require.cache[wsPath].exports = originalWebSocket;
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
});
|
package/test/live.test.js
CHANGED
|
@@ -181,7 +181,9 @@ describe('Live Integration Tests', function () {
|
|
|
181
181
|
describe('Additional Model Tests', function () {
|
|
182
182
|
|
|
183
183
|
it('should work with GPT-OSS model', async function () {
|
|
184
|
-
const model = ModelMix.new(setup).gptOss(
|
|
184
|
+
const model = ModelMix.new(setup).gptOss({
|
|
185
|
+
options: { max_tokens: 128 }
|
|
186
|
+
});
|
|
185
187
|
|
|
186
188
|
model.addText('Say "gptoss test successful" and nothing else.');
|
|
187
189
|
|
|
@@ -241,8 +243,11 @@ describe('Live Integration Tests', function () {
|
|
|
241
243
|
|
|
242
244
|
describe('JSON Structured Output for New Models', function () {
|
|
243
245
|
|
|
244
|
-
it('should return structured JSON with
|
|
245
|
-
const model = ModelMix.new(setup).
|
|
246
|
+
it('should return structured JSON with Kimi K3', async function () {
|
|
247
|
+
const model = ModelMix.new(setup).kimiK3({
|
|
248
|
+
options: { max_tokens: 512 },
|
|
249
|
+
mix: { moonshot: false, together: true }
|
|
250
|
+
});
|
|
246
251
|
|
|
247
252
|
model.addText('Generate information about a fictional vehicle.');
|
|
248
253
|
|
|
@@ -253,7 +258,7 @@ describe('Live Integration Tests', function () {
|
|
|
253
258
|
manufacturer: "Future Motors"
|
|
254
259
|
});
|
|
255
260
|
|
|
256
|
-
console.log(`
|
|
261
|
+
console.log(`Kimi K3 JSON result:`, result);
|
|
257
262
|
|
|
258
263
|
expect(result).to.be.an('object');
|
|
259
264
|
expect(result).to.have.property('name');
|