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.
@@ -0,0 +1,57 @@
1
+ function assertAbortSignal(signal) {
2
+ if (signal === undefined) return;
3
+ if (!(signal instanceof AbortSignal)) {
4
+ throw new TypeError('signal must be an AbortSignal.');
5
+ }
6
+ signal.throwIfAborted();
7
+ }
8
+
9
+ function assertNoStoredSignal(value, label) {
10
+ if (value && Object.prototype.hasOwnProperty.call(value, 'signal')) {
11
+ throw new TypeError(`${label}.signal is not supported; pass the AbortSignal to the execution method.`);
12
+ }
13
+ }
14
+
15
+ function throwIfAborted(signal) {
16
+ if (signal) signal.throwIfAborted();
17
+ }
18
+
19
+ function validateProviderExecution(provider, { config, options, signal }) {
20
+ assertAbortSignal(signal);
21
+ assertNoStoredSignal(provider.config, 'provider.config');
22
+ assertNoStoredSignal(provider.options, 'provider.options');
23
+ assertNoStoredSignal(config, 'config');
24
+ assertNoStoredSignal(options, 'options');
25
+ }
26
+
27
+ function raceWithSignal(promise, signal) {
28
+ if (!signal) return promise;
29
+ throwIfAborted(signal);
30
+ let onAbort;
31
+ const aborted = new Promise((_, reject) => {
32
+ onAbort = () => reject(signal.reason);
33
+ signal.addEventListener('abort', onAbort, { once: true });
34
+ });
35
+ return Promise.race([promise, aborted]).finally(() => {
36
+ signal.removeEventListener('abort', onAbort);
37
+ });
38
+ }
39
+
40
+ function sleepWithSignal(ms, signal) {
41
+ if (!signal) return new Promise(resolve => setTimeout(resolve, ms));
42
+ throwIfAborted(signal);
43
+ let timeout;
44
+ const sleep = new Promise(resolve => {
45
+ timeout = setTimeout(resolve, ms);
46
+ });
47
+ return raceWithSignal(sleep, signal).finally(() => clearTimeout(timeout));
48
+ }
49
+
50
+ module.exports = {
51
+ assertAbortSignal,
52
+ assertNoStoredSignal,
53
+ raceWithSignal,
54
+ sleepWithSignal,
55
+ throwIfAborted,
56
+ validateProviderExecution
57
+ };
@@ -62,7 +62,7 @@ function createAnthropicProviders({ ModelMix, MixCustom, log }) {
62
62
  });
63
63
  }
64
64
 
65
- async create({ config = {}, options = {} } = {}) {
65
+ async create({ config = {}, options = {}, signal } = {}) {
66
66
 
67
67
  delete options.response_format;
68
68
 
@@ -85,7 +85,7 @@ function createAnthropicProviders({ ModelMix, MixCustom, log }) {
85
85
  options.system = config.system;
86
86
 
87
87
  try {
88
- return await super.create({ config: requestConfig, options });
88
+ return await super.create({ config: requestConfig, options, signal });
89
89
  } catch (error) {
90
90
  // Log the error details for debugging
91
91
  if (error.response && error.response.data) {
@@ -13,10 +13,17 @@ const {
13
13
  const { configForDebug, redactSecret } = require('../provider-debug');
14
14
  const { requireProviderApiKey } = require('../provider-api-key');
15
15
  const { normalizeOpenAIOptions } = require('./openai-options');
16
+ const {
17
+ assertNoStoredSignal,
18
+ throwIfAborted,
19
+ validateProviderExecution
20
+ } = require('../abort-signal');
16
21
 
17
22
  function createBaseProviders({ ModelMix }) {
18
23
  class MixCustom {
19
24
  constructor({ config = {}, options = {}, headers = {} } = {}) {
25
+ assertNoStoredSignal(config, 'config');
26
+ assertNoStoredSignal(options, 'options');
20
27
  this.config = this.getDefaultConfig(config);
21
28
  this.options = this.getDefaultOptions(options);
22
29
  this.headers = this.getDefaultHeaders(headers);
@@ -69,7 +76,8 @@ function createBaseProviders({ ModelMix }) {
69
76
  return buildRequestBodyAndHeaders(options, headers);
70
77
  }
71
78
 
72
- async create({ config = {}, options = {} } = {}) {
79
+ async create({ config = {}, options = {}, signal } = {}) {
80
+ validateProviderExecution(this, { config, options, signal });
73
81
  try {
74
82
  this.sanitizeCacheOptions(options);
75
83
  if (Array.isArray(options.messages)) {
@@ -90,19 +98,22 @@ function createBaseProviders({ ModelMix }) {
90
98
  }
91
99
 
92
100
  if (options.stream) {
93
- return this.processStream(await fetchStreamResponse(this.config.url, {
101
+ return await this.processStream(await fetchStreamResponse(this.config.url, {
94
102
  method: 'POST',
95
103
  headers: request.headers,
96
- body: request.body
104
+ body: request.body,
105
+ signal
97
106
  }));
98
107
  } else {
99
108
  return this.processResponse(await fetchJsonResponse(this.config.url, {
100
109
  method: 'POST',
101
110
  headers: request.headers,
102
- body: request.body
111
+ body: request.body,
112
+ signal
103
113
  }));
104
114
  }
105
115
  } catch (error) {
116
+ throwIfAborted(signal);
106
117
  throw this.handleError(error);
107
118
  }
108
119
  }
@@ -285,9 +296,9 @@ function createBaseProviders({ ModelMix }) {
285
296
  });
286
297
  }
287
298
 
288
- async create({ config = {}, options = {} } = {}) {
299
+ async create({ config = {}, options = {}, signal } = {}) {
289
300
  normalizeOpenAIOptions(options);
290
- return super.create({ config, options });
301
+ return super.create({ config, options, signal });
291
302
  }
292
303
 
293
304
  static convertMessages(messages, config) {
@@ -1,6 +1,10 @@
1
1
  const { fetchJsonResponse } = require('../../http-client');
2
2
  const { configForDebug } = require('../provider-debug');
3
3
  const { requireProviderApiKey } = require('../provider-api-key');
4
+ const {
5
+ throwIfAborted,
6
+ validateProviderExecution
7
+ } = require('../abort-signal');
4
8
 
5
9
  function createGoogleProviders({ ModelMix, MixCustom }) {
6
10
  class MixGoogle extends MixCustom {
@@ -122,7 +126,8 @@ function createGoogleProviders({ ModelMix, MixCustom }) {
122
126
  }, []);
123
127
  }
124
128
 
125
- async create({ config = {}, options = {} } = {}) {
129
+ async create({ config = {}, options = {}, signal } = {}) {
130
+ validateProviderExecution(this, { config, options, signal });
126
131
  const generateContentApi = options.stream ? 'streamGenerateContent' : 'generateContent';
127
132
 
128
133
  const fullUrl = `${this.config.url}/${options.model}:${generateContentApi}?key=${this.config.apiKey}`;
@@ -187,10 +192,12 @@ function createGoogleProviders({ ModelMix, MixCustom }) {
187
192
  return this.processResponse(await fetchJsonResponse(fullUrl, {
188
193
  method: 'POST',
189
194
  headers: this.headers,
190
- body: JSON.stringify(payload)
195
+ body: JSON.stringify(payload),
196
+ signal
191
197
  }));
192
198
  }
193
199
  } catch (error) {
200
+ throwIfAborted(signal);
194
201
  throw this.handleError(error);
195
202
  }
196
203
  }
@@ -57,7 +57,7 @@ function createCompatibleProviders({ MixCustom, MixOpenAI }) {
57
57
  });
58
58
  }
59
59
 
60
- async create({ config = {}, options = {} } = {}) {
60
+ async create({ config = {}, options = {}, signal } = {}) {
61
61
 
62
62
  if (config.schema) {
63
63
  options.response_format = {
@@ -66,7 +66,7 @@ function createCompatibleProviders({ MixCustom, MixOpenAI }) {
66
66
  };
67
67
  }
68
68
 
69
- return super.create({ config, options });
69
+ return super.create({ config, options, signal });
70
70
  }
71
71
  }
72
72
 
@@ -134,11 +134,11 @@ function createCompatibleProviders({ MixCustom, MixOpenAI }) {
134
134
  });
135
135
  }
136
136
 
137
- async create({ config = {}, options = {} } = {}) {
137
+ async create({ config = {}, options = {}, signal } = {}) {
138
138
  if (options.model === GROK420_REASONING || options.model === GROK420_NON_REASONING) {
139
139
  delete options.reasoning_effort;
140
140
  }
141
- return super.create({ config, options });
141
+ return super.create({ config, options, signal });
142
142
  }
143
143
  }
144
144
 
@@ -162,14 +162,14 @@ function createCompatibleProviders({ MixCustom, MixOpenAI }) {
162
162
  });
163
163
  }
164
164
 
165
- create({ config = {}, options = {} } = {}) {
165
+ create({ config = {}, options = {}, signal } = {}) {
166
166
  if (config.schema) {
167
167
  options.response_format = {
168
168
  type: 'json_schema',
169
169
  json_schema: { schema: config.schema }
170
170
  };
171
171
  }
172
- return super.create({ config, options });
172
+ return super.create({ config, options, signal });
173
173
  }
174
174
 
175
175
  static extractThink(data) {
@@ -261,9 +261,9 @@ function createCompatibleProviders({ MixCustom, MixOpenAI }) {
261
261
  });
262
262
  }
263
263
 
264
- create({ config = {}, options = {} } = {}) {
264
+ create({ config = {}, options = {}, signal } = {}) {
265
265
  delete options.response_format;
266
- return super.create({ config, options });
266
+ return super.create({ config, options, signal });
267
267
  }
268
268
  }
269
269
 
@@ -6,6 +6,10 @@ const {
6
6
  } = require('../content-cache');
7
7
  const { requireProviderApiKey } = require('../provider-api-key');
8
8
  const { normalizeOpenAIOptions } = require('./openai-options');
9
+ const {
10
+ throwIfAborted,
11
+ validateProviderExecution
12
+ } = require('../abort-signal');
9
13
 
10
14
  function createOpenAIProviders({
11
15
  ModelMix,
@@ -17,15 +21,18 @@ function createOpenAIProviders({
17
21
  const WebSocket = require('ws');
18
22
 
19
23
  class MixOpenAIResponses extends MixOpenAI {
20
- async create({ config = {}, options = {} } = {}) {
24
+ async create({ config = {}, options = {}, signal } = {}) {
25
+ validateProviderExecution(this, { config, options, signal });
21
26
  normalizeOpenAIOptions(options);
22
27
  const responsesUrl = this.config.url.replace('/chat/completions', '/responses');
23
28
  const request = MixOpenAIResponses.buildResponsesRequest(options, config);
24
29
  const response = await fetchJsonResponse(responsesUrl, {
25
30
  method: 'POST',
26
31
  headers: this.headers,
27
- body: JSON.stringify(request)
32
+ body: JSON.stringify(request),
33
+ signal
28
34
  });
35
+ throwIfAborted(signal);
29
36
 
30
37
  return MixOpenAIResponses.processResponsesResponse(response);
31
38
  }
@@ -245,7 +252,8 @@ function createOpenAIProviders({
245
252
  });
246
253
  }
247
254
 
248
- async create({ config = {}, options = {} } = {}) {
255
+ async create({ config = {}, options = {}, signal } = {}) {
256
+ validateProviderExecution(this, { config, options, signal });
249
257
  if (options.stream) {
250
258
  throw new Error('Stream is not supported for OpenAI moderation');
251
259
  }
@@ -254,8 +262,10 @@ function createOpenAIProviders({
254
262
  const response = await fetchJsonResponse(this.config.url, {
255
263
  method: 'POST',
256
264
  headers: this.headers,
257
- body: JSON.stringify({ model: options.model, input })
265
+ body: JSON.stringify({ model: options.model, input }),
266
+ signal
258
267
  });
268
+ throwIfAborted(signal);
259
269
 
260
270
  return {
261
271
  moderation: response.data.results,
@@ -343,7 +353,8 @@ function createOpenAIProviders({
343
353
  });
344
354
  }
345
355
 
346
- async create({ config = {}, options = {} } = {}) {
356
+ async create({ config = {}, options = {}, signal } = {}) {
357
+ validateProviderExecution(this, { config, options, signal });
347
358
  normalizeOpenAIOptions(options);
348
359
  const mergedConfig = { ...this.config, ...config };
349
360
  const realtimeUrl = `${mergedConfig.realtimeUrl}?model=${encodeURIComponent(options.model)}`;
@@ -372,7 +383,18 @@ function createOpenAIProviders({
372
383
  });
373
384
  }, timeoutMs);
374
385
 
375
- const cleanUp = () => clearTimeout(timeout);
386
+ const cleanUp = () => {
387
+ clearTimeout(timeout);
388
+ signal?.removeEventListener('abort', onAbort);
389
+ };
390
+ const onAbort = () => {
391
+ if (settled) return;
392
+ settled = true;
393
+ cleanUp();
394
+ ws.terminate();
395
+ reject(signal.reason);
396
+ };
397
+ signal?.addEventListener('abort', onAbort, { once: true });
376
398
 
377
399
  ws.on('open', () => {
378
400
  const session = {
@@ -541,13 +563,13 @@ function createOpenAIProviders({
541
563
  });
542
564
  }
543
565
 
544
- async create({ config = {}, options = {} } = {}) {
566
+ async create({ config = {}, options = {}, signal } = {}) {
545
567
  if (rejectsAnthropicSamplingParams(options.model)) {
546
568
  delete options.temperature;
547
569
  delete options.top_p;
548
570
  delete options.top_k;
549
571
  }
550
- return super.create({ config, options });
572
+ return super.create({ config, options, signal });
551
573
  }
552
574
  }
553
575
 
@@ -562,7 +584,7 @@ function createOpenAIProviders({
562
584
  });
563
585
  }
564
586
 
565
- async create({ config = {}, options = {} } = {}) {
587
+ async create({ config = {}, options = {}, signal } = {}) {
566
588
  if (Object.hasOwn(options, 'max_tokens')) {
567
589
  options.max_completion_tokens = options.max_tokens;
568
590
  delete options.max_tokens;
@@ -574,7 +596,7 @@ function createOpenAIProviders({
574
596
  delete options.presence_penalty;
575
597
  delete options.frequency_penalty;
576
598
 
577
- return super.create({ config, options });
599
+ return super.create({ config, options, signal });
578
600
  }
579
601
 
580
602
  extractDelta(data) {
package/mcp-tools.js CHANGED
@@ -36,14 +36,16 @@ class MCPToolsManager {
36
36
  }
37
37
  }
38
38
 
39
- async executeTool(name, args) {
39
+ async executeTool(name, args, signal) {
40
40
  const callback = this.callbacks.get(name);
41
41
  if (!callback) {
42
42
  throw new Error(`Tool not found: ${name}`);
43
43
  }
44
44
 
45
45
  try {
46
- const result = await callback(args);
46
+ signal?.throwIfAborted();
47
+ const result = await callback(args, signal);
48
+ signal?.throwIfAborted();
47
49
  // For primitive values (numbers, booleans), convert to string
48
50
  // For objects/arrays, stringify them
49
51
  let textResult;
@@ -62,6 +64,7 @@ class MCPToolsManager {
62
64
  }]
63
65
  };
64
66
  } catch (error) {
67
+ signal?.throwIfAborted();
65
68
  log.error(`Error executing tool ${name}:`, error);
66
69
  return {
67
70
  content: [{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelmix",
3
- "version": "5.1.12",
3
+ "version": "5.1.15",
4
4
  "description": "🧬 Reliable interface with automatic fallback for AI LLMs.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -78,6 +78,6 @@
78
78
  "test:tokens": "mocha test/tokens.test.js --timeout 10000 --require test/setup.js",
79
79
  "test:plugins": "mocha test/plugins.test.js --timeout 10000 --require test/setup.js",
80
80
  "test:rlm": "mocha plugins/rlm/test/**/*.test.js --timeout 10000 --require test/setup.js",
81
- "test:offline": "mocha test/json.test.js test/fallback.test.js test/templates.test.js test/images.test.js test/bottleneck.test.js test/tokens.test.js test/history.test.js test/anthropic.test.js test/effort.test.js test/grok.test.js test/moderation.test.js test/plugins.test.js plugins/rlm/test/**/*.test.js --timeout 10000 --require test/setup.js"
81
+ "test:offline": "mocha test/abort.test.js test/json.test.js test/fallback.test.js test/templates.test.js test/images.test.js test/bottleneck.test.js test/tokens.test.js test/history.test.js test/anthropic.test.js test/effort.test.js test/grok.test.js test/moderation.test.js test/plugins.test.js plugins/rlm/test/**/*.test.js --timeout 10000 --require test/setup.js"
82
82
  }
83
83
  }
@@ -159,6 +159,7 @@ export interface RlmSandbox {
159
159
  parentExecutionId: string | null;
160
160
  depth: number;
161
161
  };
162
+ signal?: AbortSignal;
162
163
  timeoutMs: number;
163
164
  }): Promise<unknown>;
164
165
  }
@@ -21,7 +21,8 @@ function sandboxSource(code) {
21
21
 
22
22
  function createIsolatedVmSandbox() {
23
23
  return {
24
- async execute({ code, variables, query, limits, timeoutMs }) {
24
+ async execute({ code, variables, query, limits, signal, timeoutMs }) {
25
+ signal?.throwIfAborted();
25
26
  if (limits.sandboxMemoryBytes < MINIMUM_ISOLATE_MEMORY_BYTES) {
26
27
  throw new TypeError(
27
28
  `limits.sandboxMemoryBytes must be at least ${MINIMUM_ISOLATE_MEMORY_BYTES}.`
@@ -35,6 +36,10 @@ function createIsolatedVmSandbox() {
35
36
  timedOut = true;
36
37
  if (!isolate.isDisposed) isolate.dispose();
37
38
  }, timeoutMs);
39
+ const onAbort = () => {
40
+ if (!isolate.isDisposed) isolate.dispose();
41
+ };
42
+ signal?.addEventListener('abort', onAbort, { once: true });
38
43
 
39
44
  try {
40
45
  const context = await isolate.createContext();
@@ -68,6 +73,7 @@ function createIsolatedVmSandbox() {
68
73
  timeout: timeoutMs
69
74
  });
70
75
  } catch (error) {
76
+ signal?.throwIfAborted();
71
77
  if (memoryError(error)) {
72
78
  throw new RlmLimitError(
73
79
  'sandboxMemoryBytes',
@@ -78,6 +84,7 @@ function createIsolatedVmSandbox() {
78
84
  throw error;
79
85
  } finally {
80
86
  clearTimeout(timeout);
87
+ signal?.removeEventListener('abort', onAbort);
81
88
  if (!isolate.isDisposed) isolate.dispose();
82
89
  }
83
90
  }
@@ -13,7 +13,7 @@ const { isPlainObject } = require('./validation');
13
13
 
14
14
  function validateSandbox(sandbox) {
15
15
  if (!sandbox || typeof sandbox !== 'object' || typeof sandbox.execute !== 'function') {
16
- throw new TypeError('sandbox must define execute({ code, variables, query, limits }).');
16
+ throw new TypeError('sandbox must define execute({ code, variables, query, limits, signal }).');
17
17
  }
18
18
  return sandbox;
19
19
  }
@@ -91,9 +91,11 @@ function executionInput(context, configuredVariables) {
91
91
  };
92
92
  }
93
93
 
94
- function runWithTimeout(operation, timeoutMs) {
94
+ function runWithTimeout(operation, timeoutMs, signal) {
95
+ signal?.throwIfAborted();
95
96
  let timeout;
96
- return Promise.race([
97
+ let onAbort;
98
+ const promises = [
97
99
  operation(),
98
100
  new Promise((_, reject) => {
99
101
  timeout = setTimeout(() => reject(new RlmLimitError(
@@ -101,7 +103,17 @@ function runWithTimeout(operation, timeoutMs) {
101
103
  'RLM wall-time limit exceeded.'
102
104
  )), timeoutMs);
103
105
  })
104
- ]).finally(() => clearTimeout(timeout));
106
+ ];
107
+ if (signal) {
108
+ promises.push(new Promise((_, reject) => {
109
+ onAbort = () => reject(signal.reason);
110
+ signal.addEventListener('abort', onAbort, { once: true });
111
+ }));
112
+ }
113
+ return Promise.race(promises).finally(() => {
114
+ clearTimeout(timeout);
115
+ if (onAbort) signal.removeEventListener('abort', onAbort);
116
+ });
105
117
  }
106
118
 
107
119
  function rlm({ maxDepth, variables = {}, documents, workers, limits, sandbox } = {}) {
@@ -122,6 +134,7 @@ function rlm({ maxDepth, variables = {}, documents, workers, limits, sandbox } =
122
134
  return {
123
135
  name: 'rlm',
124
136
  async execute(context) {
137
+ context.signal?.throwIfAborted();
125
138
  if (context.request.outputMode === 'stream') {
126
139
  throw new Error('RLM streaming is not supported; use a buffered output mode.');
127
140
  }
@@ -145,6 +158,7 @@ function rlm({ maxDepth, variables = {}, documents, workers, limits, sandbox } =
145
158
  outputSchema: context.request.config.schema || null
146
159
  })
147
160
  ));
161
+ context.signal?.throwIfAborted();
148
162
  state.record('planner', plannerResult, {
149
163
  worker: null,
150
164
  elapsedMs: Date.now() - plannerStartedAt
@@ -167,10 +181,13 @@ function rlm({ maxDepth, variables = {}, documents, workers, limits, sandbox } =
167
181
  query,
168
182
  limits: validatedLimits,
169
183
  execution: context.execution,
184
+ signal: context.signal,
170
185
  timeoutMs
171
186
  }),
172
- timeoutMs
187
+ timeoutMs,
188
+ context.signal
173
189
  );
190
+ context.signal?.throwIfAborted();
174
191
  const message = serializeResult(value);
175
192
  state.budget.accountFinalOutput(message);
176
193
  return {
@@ -179,6 +196,7 @@ function rlm({ maxDepth, variables = {}, documents, workers, limits, sandbox } =
179
196
  rlm: state.diagnostics(context.execution)
180
197
  };
181
198
  } catch (error) {
199
+ context.signal?.throwIfAborted();
182
200
  error.rlm = state.diagnostics(
183
201
  context.execution,
184
202
  error.limit ? `limit:${error.limit}` : 'error'
@@ -69,6 +69,29 @@ describe('RLM isolated-vm sandbox', () => {
69
69
  expect(failure.limit).to.equal('maxWallTimeMs');
70
70
  });
71
71
 
72
+ it('disposes an in-flight isolate and preserves the abort reason', async () => {
73
+ const controller = new AbortController();
74
+ const reason = new Error('stop sandbox');
75
+ const execution = createIsolatedVmSandbox().execute({
76
+ code: '(async () => { while (true) {} })()',
77
+ variables: {},
78
+ query: async () => '',
79
+ limits: limits(),
80
+ signal: controller.signal,
81
+ timeoutMs: 1000
82
+ });
83
+
84
+ setTimeout(() => controller.abort(reason), 20);
85
+
86
+ let failure;
87
+ try {
88
+ await execution;
89
+ } catch (error) {
90
+ failure = error;
91
+ }
92
+ expect(failure).to.equal(reason);
93
+ });
94
+
72
95
  it('rejects memory limits below the isolated-vm minimum', async () => {
73
96
  let failure;
74
97
  try {
@@ -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({
@@ -332,6 +332,24 @@ const raw = await ModelMix.new()
332
332
  // raw.message, raw.think, raw.tokens, raw.toolCalls, raw.response
333
333
  ```
334
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
+
335
353
  ### Access full response with lastRaw
336
354
 
337
355
  After calling `message()`, `json()`, `block()`, or `stream()`, use `lastRaw` to access the complete response:
@@ -614,12 +632,12 @@ const model = ModelMix.new({
614
632
  | `.assign({})` | `this` | Assign EJS template data |
615
633
  | `.assignKey(key, value)` | `this` | Assign one EJS template-data value |
616
634
  | `.assignKeyFromFile(key, path)` | `this` | Assign the rendered output of an EJS file to one key |
617
- | `.message()` | `Promise<string>` | Get text response |
618
- | `.json(example, desc?, opts?)` | `Promise<object\|array>` | Get structured JSON |
619
- | `.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 |
620
638
  | `.lastRaw` | `object \| null` | Full response from last call |
621
- | `.stream(callback)` | `Promise` | Stream response |
622
- | `.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 |
623
641
  | `.addMCP(package)` | `Promise` | Add MCP server tools |
624
642
  | `.addTool(def, callback)` | `this` | Register custom local tool |
625
643
  | `.addTools([{tool, callback}])` | `this` | Register multiple tools |