modelmix 5.1.10 → 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.
Files changed (45) hide show
  1. package/README.md +42 -10
  2. package/demo/free.js +1 -3
  3. package/demo/short.js +15 -13
  4. package/effort.js +13 -7
  5. package/http-client.js +6 -6
  6. package/index.d.ts +13 -6
  7. package/index.js +204 -144
  8. package/lib/abort-signal.js +57 -0
  9. package/lib/model-chain.js +2 -2
  10. package/lib/provider-api-key.js +9 -0
  11. package/lib/providers/anthropic.js +24 -22
  12. package/lib/providers/base.js +21 -28
  13. package/lib/providers/google.js +12 -8
  14. package/lib/providers/openai-compatible.js +19 -48
  15. package/lib/providers/openai-options.js +16 -0
  16. package/lib/providers/openai.js +48 -47
  17. package/lib/providers.js +5 -2
  18. package/lib/token-usage.js +19 -4
  19. package/mcp-tools.js +5 -2
  20. package/package.json +3 -2
  21. package/plugins/rlm/index.d.ts +1 -0
  22. package/plugins/rlm/lib/budget.js +2 -7
  23. package/plugins/rlm/lib/isolated-vm-sandbox.js +8 -1
  24. package/plugins/rlm/lib/planner-prompt.js +1 -7
  25. package/plugins/rlm/lib/plugin.js +24 -11
  26. package/plugins/rlm/lib/validation.js +14 -0
  27. package/plugins/rlm/lib/variable-descriptors.js +1 -5
  28. package/plugins/rlm/lib/worker-catalog.js +1 -5
  29. package/plugins/rlm/test/isolated-vm-sandbox.test.js +23 -0
  30. package/pnpm-workspace.yaml +10 -5
  31. package/schema.js +0 -28
  32. package/skills/modelmix/SKILL.md +34 -12
  33. package/test/abort.test.js +517 -0
  34. package/test/anthropic.test.js +77 -1
  35. package/test/bottleneck.test.js +2 -2
  36. package/test/effort.test.js +24 -1
  37. package/test/fallback.test.js +101 -1
  38. package/test/glm.test.js +2 -6
  39. package/test/kimi.test.js +2 -6
  40. package/test/live.test.js +10 -5
  41. package/test/muse.test.js +24 -7
  42. package/test/provider-expansion.test.js +17 -0
  43. package/test/public-api.test.js +2 -2
  44. package/test/qwen.test.js +2 -3
  45. package/test/tokens.test.js +23 -8
@@ -105,7 +105,7 @@ const model = ModelMix.new()
105
105
 
106
106
  ### Instance plugins
107
107
 
108
- Register middleware with `.use({ name, execute })`. Plugins are scoped to the instance, run in registration order, and receive the rendered provider-neutral request. They may edit `context.request`, call `next()`, or return a complete ModelMix result.
108
+ Register middleware with `.use({ name, execute })`. Plugins are scoped to the instance, run in registration order, and receive the rendered provider-neutral request. They may edit `context.request`, call `next()`, or return a complete ModelMix result. `context.signal` is the current execution's `AbortSignal` and is inherited by `context.invoke()` children.
109
109
 
110
110
  ```javascript
111
111
  model.use({
@@ -151,13 +151,17 @@ ModelMix.new({ config: { effort: 80 } })
151
151
 
152
152
  Use `ModerationMix.new().openai()` with `.raw()` to classify text and images through OpenAI's Moderations endpoint. Read the results from `raw.moderation`. `ModerationMix` accepts moderation providers as ordered fallbacks, rejects generative providers, and does not generate text or support streaming.
153
153
 
154
- `gpt52()` `gpt52chat()` `gpt51()` `gpt5()` `gpt5mini()` `gpt5nano()` `gpt45()` `o3()` `o4mini()`
154
+ `gpt56sol()` `gpt56terra()` `gpt56luna()` `gpt55()` `gpt55pro()` `gpt54()` `gpt54mini()` `gpt54nano()` `gpt54pro()` `gpt53codex()` `gpt53chat()` `gpt52()` `gpt51()` `gpt5()` `gpt5mini()` `gpt5nano()` `gptRealtime()` `gptRealtimeMini()` `gptOss()`
155
+
156
+ Every textual GPT-5 shortcut registers only the official OpenAI model by default. Pass `mix: { openrouter: true }` to `ModelMix.new()` or to an individual shortcut to append its `openai/*` OpenRouter route as a fallback. `gpt53chat()` uses `gpt-5.3-chat-latest` officially and `openai/gpt-5.3-chat` through OpenRouter. Both API keys are required when that fallback is enabled. Realtime shortcuts remain official-only.
155
157
 
156
158
  ### Anthropic
157
- `fable50()` `opus50()` `opus48()` `opus47()` `opus46()` `sonnet5()` `sonnet46()` `sonnet45()` `haiku45()`
159
+ `fable51()` `fable50()` `opus50()` `opus48()` `opus47()` `opus46()` `sonnet5()` `sonnet46()` `sonnet45()` `haiku45()`
158
160
 
159
161
  Use `.effort(n)` (or `config.effort`) to enable Anthropic thinking — e.g. `.effort(100).opus50()`. `fable5()` and `opus5()` remain available as compatibility aliases.
160
162
 
163
+ `fable51()` registers `claude-fable-5-1` through Anthropic by default. Pass `mix: { openrouter: true }` to append `anthropic/claude-fable-5.1` as its fallback.
164
+
161
165
  ### Google
162
166
  `gemini31pro()` `gemini37flash()` `gemini36flash()` `gemini35flash()` `gemini35flashLite()` `gemini31flashLite()`
163
167
 
@@ -186,7 +190,7 @@ Use `.effort(n)` (or `config.effort`) to enable Anthropic thinking — e.g. `.ef
186
190
  `GLM46()`
187
191
 
188
192
  ### OpenRouter
189
- `museGlimmer30b()` `gptOss()` `qwen35397b()` `qwen36plus()` `qwen37plus()` `qwen3827b()` `qwen38flash()` `hermes470b()` `hermes4405b()` `qwen38max()` `kimiK27Code()` `kimiK3()` `minimaxM27()` `minimaxM3()` `GLM45()` `GLM52()` `GLM53()` `GLM53Flash()`
193
+ `museGlimmer30b()` `museSpark12Contributor()` `gptOss()` `qwen35397b()` `qwen36plus()` `qwen37plus()` `qwen3827b()` `qwen38flash()` `hermes470b()` `hermes4405b()` `qwen38max()` `kimiK27Code()` `kimiK3()` `minimaxM27()` `minimaxM3()` `GLM45()` `GLM52()` `GLM53()` `GLM53Flash()`
190
194
 
191
195
  ### Multi-provider (auto-fallback across free/paid tiers)
192
196
  `hermes3()` `kimiK25()`
@@ -328,6 +332,24 @@ const raw = await ModelMix.new()
328
332
  // raw.message, raw.think, raw.tokens, raw.toolCalls, raw.response
329
333
  ```
330
334
 
335
+ ### Cancel one execution
336
+
337
+ Pass an `AbortSignal` directly to the terminal method:
338
+
339
+ ```javascript
340
+ const controller = new AbortController();
341
+ const pending = model.message(controller.signal);
342
+ controller.abort(new Error('Request cancelled'));
343
+ await pending;
344
+ ```
345
+
346
+ The other forms are `raw(signal)`, `stream(callback, signal)`,
347
+ `block(options, signal)`, and `json(example, description, jsonOptions, signal)`.
348
+ For `execute()`, use the top-level `{ signal }` field. Never place `signal` in
349
+ `config` or `options`; ModelMix rejects those reusable/payload-bearing locations.
350
+ Plugins read `context.signal`, child invocations inherit it, and local tool
351
+ callbacks receive it as their second argument.
352
+
331
353
  ### Access full response with lastRaw
332
354
 
333
355
  After calling `message()`, `json()`, `block()`, or `stream()`, use `lastRaw` to access the complete response:
@@ -551,10 +573,9 @@ For full debug output, also set: `DEBUG=ModelMix* node script.js`
551
573
  ### Free-tier models
552
574
 
553
575
  ```javascript
554
- const model = ModelMix.new({ mix: { openrouter: false } })
576
+ const model = ModelMix.new()
555
577
  .gptOss()
556
578
  .kimiK25()
557
- .hermes3()
558
579
  .addText("What is the capital of France?");
559
580
  console.log(await model.message());
560
581
  ```
@@ -568,7 +589,8 @@ Some model shorthands register the same model across multiple providers for maxi
568
589
  ```javascript
569
590
  const model = ModelMix.new({
570
591
  mix: {
571
- openrouter: true, // default: true
592
+ anthropic: true, // fable51() default: true
593
+ openrouter: false, // fallback default: false
572
594
  cerebras: true, // default: true
573
595
  groq: true, // default: true
574
596
  together: false, // default: false
@@ -610,12 +632,12 @@ const model = ModelMix.new({
610
632
  | `.assign({})` | `this` | Assign EJS template data |
611
633
  | `.assignKey(key, value)` | `this` | Assign one EJS template-data value |
612
634
  | `.assignKeyFromFile(key, path)` | `this` | Assign the rendered output of an EJS file to one key |
613
- | `.message()` | `Promise<string>` | Get text response |
614
- | `.json(example, desc?, opts?)` | `Promise<object\|array>` | Get structured JSON |
615
- | `.raw()` | `Promise<{message, think, toolCalls, tokens, response}>` | Full response |
635
+ | `.message(signal?)` | `Promise<string>` | Get text response |
636
+ | `.json(example, desc?, opts?, signal?)` | `Promise<object\|array>` | Get structured JSON |
637
+ | `.raw(signal?)` | `Promise<{message, think, toolCalls, tokens, response}>` | Full response |
616
638
  | `.lastRaw` | `object \| null` | Full response from last call |
617
- | `.stream(callback)` | `Promise` | Stream response |
618
- | `.block({addSystemExtra?})` | `Promise<string>` | Extract code block from response |
639
+ | `.stream(callback, signal?)` | `Promise` | Stream response |
640
+ | `.block({addSystemExtra?}, signal?)` | `Promise<string>` | Extract code block from response |
619
641
  | `.addMCP(package)` | `Promise` | Add MCP server tools |
620
642
  | `.addTool(def, callback)` | `this` | Register custom local tool |
621
643
  | `.addTools([{tool, callback}])` | `this` | Register multiple tools |
@@ -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
+ });