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
@@ -4,38 +4,35 @@ const {
4
4
  normalizeContentCache,
5
5
  stripContentCacheMetadata
6
6
  } = require('../content-cache');
7
+ const { requireProviderApiKey } = require('../provider-api-key');
8
+ const { normalizeOpenAIOptions } = require('./openai-options');
9
+ const {
10
+ throwIfAborted,
11
+ validateProviderExecution
12
+ } = require('../abort-signal');
7
13
 
8
14
  function createOpenAIProviders({
9
15
  ModelMix,
10
16
  MixCustom,
11
17
  MixOpenAI,
12
- MixModeration
18
+ MixModeration,
19
+ rejectsAnthropicSamplingParams
13
20
  }) {
14
21
  const WebSocket = require('ws');
15
22
 
16
23
  class MixOpenAIResponses extends MixOpenAI {
17
- async create({ config = {}, options = {} } = {}) {
18
-
19
- // Keep GPT/o-model option normalization behavior
20
- if (options.model?.startsWith('o')) {
21
- delete options.max_tokens;
22
- delete options.temperature;
23
- }
24
- if (options.model?.includes('gpt-5')) {
25
- if (options.max_tokens) {
26
- options.max_completion_tokens = options.max_tokens;
27
- delete options.max_tokens;
28
- }
29
- delete options.temperature;
30
- }
31
-
24
+ async create({ config = {}, options = {}, signal } = {}) {
25
+ validateProviderExecution(this, { config, options, signal });
26
+ normalizeOpenAIOptions(options);
32
27
  const responsesUrl = this.config.url.replace('/chat/completions', '/responses');
33
28
  const request = MixOpenAIResponses.buildResponsesRequest(options, config);
34
29
  const response = await fetchJsonResponse(responsesUrl, {
35
30
  method: 'POST',
36
31
  headers: this.headers,
37
- body: JSON.stringify(request)
32
+ body: JSON.stringify(request),
33
+ signal
38
34
  });
35
+ throwIfAborted(signal);
39
36
 
40
37
  return MixOpenAIResponses.processResponsesResponse(response);
41
38
  }
@@ -246,10 +243,7 @@ function createOpenAIProviders({
246
243
 
247
244
  class MixOpenAIModeration extends MixModeration {
248
245
  getDefaultConfig(customConfig) {
249
- const apiKey = customConfig.apiKey || process.env.OPENAI_API_KEY;
250
- if (!apiKey) {
251
- throw new Error('OpenAI API key not found. Please provide it in config or set OPENAI_API_KEY environment variable.');
252
- }
246
+ const apiKey = requireProviderApiKey(customConfig, 'OPENAI_API_KEY', 'OpenAI');
253
247
 
254
248
  return super.getDefaultConfig({
255
249
  url: 'https://api.openai.com/v1/moderations',
@@ -258,7 +252,8 @@ function createOpenAIProviders({
258
252
  });
259
253
  }
260
254
 
261
- async create({ config = {}, options = {} } = {}) {
255
+ async create({ config = {}, options = {}, signal } = {}) {
256
+ validateProviderExecution(this, { config, options, signal });
262
257
  if (options.stream) {
263
258
  throw new Error('Stream is not supported for OpenAI moderation');
264
259
  }
@@ -267,8 +262,10 @@ function createOpenAIProviders({
267
262
  const response = await fetchJsonResponse(this.config.url, {
268
263
  method: 'POST',
269
264
  headers: this.headers,
270
- body: JSON.stringify({ model: options.model, input })
265
+ body: JSON.stringify({ model: options.model, input }),
266
+ signal
271
267
  });
268
+ throwIfAborted(signal);
272
269
 
273
270
  return {
274
271
  moderation: response.data.results,
@@ -356,19 +353,9 @@ function createOpenAIProviders({
356
353
  });
357
354
  }
358
355
 
359
- async create({ config = {}, options = {} } = {}) {
360
- if (options.model?.startsWith('o')) {
361
- delete options.max_tokens;
362
- delete options.temperature;
363
- }
364
- if (options.model?.includes('gpt-5')) {
365
- if (options.max_tokens) {
366
- options.max_completion_tokens = options.max_tokens;
367
- delete options.max_tokens;
368
- }
369
- delete options.temperature;
370
- }
371
-
356
+ async create({ config = {}, options = {}, signal } = {}) {
357
+ validateProviderExecution(this, { config, options, signal });
358
+ normalizeOpenAIOptions(options);
372
359
  const mergedConfig = { ...this.config, ...config };
373
360
  const realtimeUrl = `${mergedConfig.realtimeUrl}?model=${encodeURIComponent(options.model)}`;
374
361
  const timeoutMs = mergedConfig.websocketTimeoutMs || 120000;
@@ -396,7 +383,18 @@ function createOpenAIProviders({
396
383
  });
397
384
  }, timeoutMs);
398
385
 
399
- 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 });
400
398
 
401
399
  ws.on('open', () => {
402
400
  const session = {
@@ -556,10 +554,7 @@ function createOpenAIProviders({
556
554
 
557
555
  class MixOpenRouter extends MixOpenAI {
558
556
  getDefaultConfig(customConfig) {
559
- const apiKey = customConfig.apiKey || process.env.OPENROUTER_API_KEY;
560
- if (!apiKey) {
561
- throw new Error('OpenRouter API key not found. Please provide it in config or set OPENROUTER_API_KEY environment variable.');
562
- }
557
+ const apiKey = requireProviderApiKey(customConfig, 'OPENROUTER_API_KEY', 'OpenRouter');
563
558
 
564
559
  return MixCustom.prototype.getDefaultConfig.call(this, {
565
560
  url: 'https://openrouter.ai/api/v1/chat/completions',
@@ -567,14 +562,20 @@ function createOpenAIProviders({
567
562
  ...customConfig
568
563
  });
569
564
  }
565
+
566
+ async create({ config = {}, options = {}, signal } = {}) {
567
+ if (rejectsAnthropicSamplingParams(options.model)) {
568
+ delete options.temperature;
569
+ delete options.top_p;
570
+ delete options.top_k;
571
+ }
572
+ return super.create({ config, options, signal });
573
+ }
570
574
  }
571
575
 
572
576
  class MixKimi extends MixOpenAI {
573
577
  getDefaultConfig(customConfig) {
574
- const apiKey = customConfig.apiKey || process.env.MOONSHOT_API_KEY;
575
- if (!apiKey) {
576
- throw new Error('Moonshot API key not found. Please provide it in config or set MOONSHOT_API_KEY environment variable.');
577
- }
578
+ const apiKey = requireProviderApiKey(customConfig, 'MOONSHOT_API_KEY', 'Moonshot');
578
579
 
579
580
  return MixCustom.prototype.getDefaultConfig.call(this, {
580
581
  url: 'https://api.moonshot.ai/v1/chat/completions',
@@ -583,7 +584,7 @@ function createOpenAIProviders({
583
584
  });
584
585
  }
585
586
 
586
- async create({ config = {}, options = {} } = {}) {
587
+ async create({ config = {}, options = {}, signal } = {}) {
587
588
  if (Object.hasOwn(options, 'max_tokens')) {
588
589
  options.max_completion_tokens = options.max_tokens;
589
590
  delete options.max_tokens;
@@ -595,7 +596,7 @@ function createOpenAIProviders({
595
596
  delete options.presence_penalty;
596
597
  delete options.frequency_penalty;
597
598
 
598
- return super.create({ config, options });
599
+ return super.create({ config, options, signal });
599
600
  }
600
601
 
601
602
  extractDelta(data) {
package/lib/providers.js CHANGED
@@ -1,12 +1,15 @@
1
1
  const createBaseProviders = require('./providers/base');
2
2
  const createOpenAIProviders = require('./providers/openai');
3
- const createAnthropicProviders = require('./providers/anthropic');
3
+ const {
4
+ createAnthropicProviders,
5
+ rejectsAnthropicSamplingParams
6
+ } = require('./providers/anthropic');
4
7
  const createCompatibleProviders = require('./providers/openai-compatible');
5
8
  const createGoogleProviders = require('./providers/google');
6
9
 
7
10
  function createProviders({ ModelMix, log }) {
8
11
  const base = createBaseProviders({ ModelMix });
9
- const openai = createOpenAIProviders({ ModelMix, ...base });
12
+ const openai = createOpenAIProviders({ ModelMix, rejectsAnthropicSamplingParams, ...base });
10
13
  const anthropic = createAnthropicProviders({ ModelMix, MixCustom: base.MixCustom, log });
11
14
  const compatible = createCompatibleProviders({
12
15
  MixCustom: base.MixCustom,
@@ -35,6 +35,8 @@ const MODEL_PRICING = {
35
35
  'gpt-5.3-codex': { input: 1.75, cachedInput: 0.175, output: 14.00 },
36
36
  'gpt-5.2': { input: 1.75, cachedInput: 0.175, output: 14.00 },
37
37
  'gpt-5.2-chat-latest': { input: 1.75, cachedInput: 0.175, output: 14.00 },
38
+ 'gpt-5.3-chat-latest': { input: 1.75, cachedInput: 0.175, output: 14.00 },
39
+ 'openai/gpt-5.3-chat': { input: 1.75, cachedInput: 0.175, output: 14.00 },
38
40
  'gpt-5.1': { input: 1.25, cachedInput: 0.125, output: 10.00 },
39
41
  'gpt-5': { input: 1.25, cachedInput: 0.125, output: 10.00 },
40
42
  'gpt-5-mini': { input: 0.25, cachedInput: 0.025, output: 2.00 },
@@ -47,6 +49,8 @@ const MODEL_PRICING = {
47
49
  'gpt-oss-120b': { input: 0.15, output: 0.60 },
48
50
  'accounts/fireworks/models/gpt-oss-120b': { input: 0.15, cachedInput: 0.014, output: 0.60 },
49
51
  // Anthropic
52
+ 'claude-fable-5-1': { input: 10.00, cachedInput: 0.25, cacheWrite: 12.50, cacheWrite1h: 20.00, output: 50.00 },
53
+ 'anthropic/claude-fable-5.1': { input: 10.00, cachedInput: 0.25, cacheWrite: 12.50, cacheWrite1h: 20.00, output: 50.00 },
50
54
  'claude-fable-5': { input: 10.00, cachedInput: 1.00, cacheWrite: 12.50, cacheWrite1h: 20.00, output: 50.00 },
51
55
  'claude-opus-5': { input: 5.00, cachedInput: 0.50, cacheWrite: 6.25, cacheWrite1h: 10.00, output: 25.00 },
52
56
  'claude-sonnet-5': { input: 3.00, cachedInput: 0.30, cacheWrite: 3.75, cacheWrite1h: 6.00, output: 15.00 },
@@ -75,10 +79,11 @@ const MODEL_PRICING = {
75
79
  'grok-4.20-0309': { input: 1.25, output: 2.50 },
76
80
  'grok-4.20-0309-reasoning': { input: 1.25, output: 2.50 },
77
81
  'grok-4.20-0309-non-reasoning': { input: 1.25, output: 2.50 },
78
- // Muse Glimmer 30B (NVIDIA/OpenRouter, Fireworks, Together)
82
+ // Muse
79
83
  'meta/muse-glimmer-30b': { input: 0.35, cachedInput: 0.04, output: 1.50 },
80
84
  'accounts/fireworks/models/muse-glimmer-30b': { input: 0.35, cachedInput: 0.04, output: 1.50 },
81
85
  'meta-models/Muse-Glimmer-30B': { input: 0.35, cachedInput: 0.04, output: 1.50 },
86
+ 'meta/muse-spark-1.2-contributor': { input: 0.10, cachedInput: 0.002, output: 0.20 },
82
87
  // Fireworks
83
88
  'accounts/fireworks/models/deepseek-v4-flash': { input: 0.14, output: 0.28 },
84
89
  'accounts/fireworks/models/deepseek-v4-pro': { input: 1.74, output: 3.48 },
@@ -200,7 +205,7 @@ function normalizeTokenUsage({ input = 0, output = 0, thinking = 0, total, cache
200
205
  }
201
206
 
202
207
  function calculateCostBreakdown(modelKey, tokens) {
203
- const pricing = MODEL_PRICING[modelKey];
208
+ const pricing = getModelPricing(modelKey);
204
209
  if (!pricing) return normalizeTokenUsage().costBreakdown;
205
210
 
206
211
  const normalized = normalizeTokenUsage(tokens);
@@ -249,7 +254,7 @@ function calculateCostBreakdown(modelKey, tokens) {
249
254
  }
250
255
 
251
256
  function calculateCacheMetrics(modelKey, tokens) {
252
- const pricing = MODEL_PRICING[modelKey];
257
+ const pricing = getModelPricing(modelKey);
253
258
  const emptyMetrics = {
254
259
  cacheSavings: 0,
255
260
  cacheWritePremium: 0,
@@ -296,7 +301,17 @@ function calculateCost(modelKey, tokens) {
296
301
  }
297
302
 
298
303
  function hasModelPricing(modelKey) {
299
- return Object.prototype.hasOwnProperty.call(MODEL_PRICING, modelKey);
304
+ return getModelPricing(modelKey) !== undefined;
305
+ }
306
+
307
+ function getModelPricing(modelKey) {
308
+ if (Object.prototype.hasOwnProperty.call(MODEL_PRICING, modelKey)) {
309
+ return MODEL_PRICING[modelKey];
310
+ }
311
+ if (typeof modelKey === 'string' && modelKey.startsWith('openai/')) {
312
+ return MODEL_PRICING[modelKey.slice('openai/'.length)];
313
+ }
314
+ return undefined;
300
315
  }
301
316
 
302
317
  function extractCacheTokens(usage = {}) {
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.10",
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",
@@ -27,6 +27,7 @@
27
27
  "gpt5",
28
28
  "opus50",
29
29
  "opus5",
30
+ "fable51",
30
31
  "fable50",
31
32
  "fable",
32
33
  "sonnet",
@@ -77,6 +78,6 @@
77
78
  "test:tokens": "mocha test/tokens.test.js --timeout 10000 --require test/setup.js",
78
79
  "test:plugins": "mocha test/plugins.test.js --timeout 10000 --require test/setup.js",
79
80
  "test:rlm": "mocha plugins/rlm/test/**/*.test.js --timeout 10000 --require test/setup.js",
80
- "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"
81
82
  }
82
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
  }
@@ -1,3 +1,5 @@
1
+ const { positiveInteger } = require('./validation');
2
+
1
3
  class RlmLimitError extends Error {
2
4
  constructor(limit, message) {
3
5
  super(message);
@@ -6,13 +8,6 @@ class RlmLimitError extends Error {
6
8
  }
7
9
  }
8
10
 
9
- function positiveInteger(value, name) {
10
- if (!Number.isInteger(value) || value <= 0) {
11
- throw new TypeError(`${name} must be a positive integer.`);
12
- }
13
- return value;
14
- }
15
-
16
11
  function validateRuntimeLimits(limits) {
17
12
  if (!limits || typeof limits !== 'object' || Array.isArray(limits)) {
18
13
  throw new TypeError('limits must be a plain object.');
@@ -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
  }
@@ -1,15 +1,9 @@
1
1
  const path = require('path');
2
2
  const { describeVariables } = require('./variable-descriptors');
3
+ const { positiveInteger } = require('./validation');
3
4
 
4
5
  const PLANNER_SYSTEM_TEMPLATE = path.resolve(__dirname, '../prompts/planner.md');
5
6
 
6
- function positiveInteger(value, name) {
7
- if (!Number.isInteger(value) || value <= 0) {
8
- throw new TypeError(`${name} must be a positive integer.`);
9
- }
10
- return value;
11
- }
12
-
13
7
  function planningHint(name, descriptor, maxQueryBytes) {
14
8
  const payloadBytes = descriptor.utf8Bytes ?? descriptor.estimatedBytes;
15
9
  if (payloadBytes <= maxQueryBytes) {
@@ -9,16 +9,11 @@ const {
9
9
  sumTokens
10
10
  } = require('./runtime');
11
11
  const { createWorkerCatalog } = require('./worker-catalog');
12
-
13
- function isPlainObject(value) {
14
- if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
15
- const prototype = Object.getPrototypeOf(value);
16
- return prototype === Object.prototype || prototype === null;
17
- }
12
+ const { isPlainObject } = require('./validation');
18
13
 
19
14
  function validateSandbox(sandbox) {
20
15
  if (!sandbox || typeof sandbox !== 'object' || typeof sandbox.execute !== 'function') {
21
- throw new TypeError('sandbox must define execute({ code, variables, query, limits }).');
16
+ throw new TypeError('sandbox must define execute({ code, variables, query, limits, signal }).');
22
17
  }
23
18
  return sandbox;
24
19
  }
@@ -96,9 +91,11 @@ function executionInput(context, configuredVariables) {
96
91
  };
97
92
  }
98
93
 
99
- function runWithTimeout(operation, timeoutMs) {
94
+ function runWithTimeout(operation, timeoutMs, signal) {
95
+ signal?.throwIfAborted();
100
96
  let timeout;
101
- return Promise.race([
97
+ let onAbort;
98
+ const promises = [
102
99
  operation(),
103
100
  new Promise((_, reject) => {
104
101
  timeout = setTimeout(() => reject(new RlmLimitError(
@@ -106,7 +103,17 @@ function runWithTimeout(operation, timeoutMs) {
106
103
  'RLM wall-time limit exceeded.'
107
104
  )), timeoutMs);
108
105
  })
109
- ]).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
+ });
110
117
  }
111
118
 
112
119
  function rlm({ maxDepth, variables = {}, documents, workers, limits, sandbox } = {}) {
@@ -127,6 +134,7 @@ function rlm({ maxDepth, variables = {}, documents, workers, limits, sandbox } =
127
134
  return {
128
135
  name: 'rlm',
129
136
  async execute(context) {
137
+ context.signal?.throwIfAborted();
130
138
  if (context.request.outputMode === 'stream') {
131
139
  throw new Error('RLM streaming is not supported; use a buffered output mode.');
132
140
  }
@@ -150,6 +158,7 @@ function rlm({ maxDepth, variables = {}, documents, workers, limits, sandbox } =
150
158
  outputSchema: context.request.config.schema || null
151
159
  })
152
160
  ));
161
+ context.signal?.throwIfAborted();
153
162
  state.record('planner', plannerResult, {
154
163
  worker: null,
155
164
  elapsedMs: Date.now() - plannerStartedAt
@@ -172,10 +181,13 @@ function rlm({ maxDepth, variables = {}, documents, workers, limits, sandbox } =
172
181
  query,
173
182
  limits: validatedLimits,
174
183
  execution: context.execution,
184
+ signal: context.signal,
175
185
  timeoutMs
176
186
  }),
177
- timeoutMs
187
+ timeoutMs,
188
+ context.signal
178
189
  );
190
+ context.signal?.throwIfAborted();
179
191
  const message = serializeResult(value);
180
192
  state.budget.accountFinalOutput(message);
181
193
  return {
@@ -184,6 +196,7 @@ function rlm({ maxDepth, variables = {}, documents, workers, limits, sandbox } =
184
196
  rlm: state.diagnostics(context.execution)
185
197
  };
186
198
  } catch (error) {
199
+ context.signal?.throwIfAborted();
187
200
  error.rlm = state.diagnostics(
188
201
  context.execution,
189
202
  error.limit ? `limit:${error.limit}` : 'error'
@@ -0,0 +1,14 @@
1
+ function isPlainObject(value) {
2
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
3
+ const prototype = Object.getPrototypeOf(value);
4
+ return prototype === Object.prototype || prototype === null;
5
+ }
6
+
7
+ function positiveInteger(value, name) {
8
+ if (!Number.isInteger(value) || value <= 0) {
9
+ throw new TypeError(`${name} must be a positive integer.`);
10
+ }
11
+ return value;
12
+ }
13
+
14
+ module.exports = { isPlainObject, positiveInteger };
@@ -1,8 +1,4 @@
1
- function isPlainObject(value) {
2
- if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
3
- const prototype = Object.getPrototypeOf(value);
4
- return prototype === Object.prototype || prototype === null;
5
- }
1
+ const { isPlainObject } = require('./validation');
6
2
 
7
3
  function roundAverage(total, count) {
8
4
  return count === 0 ? 0 : Number((total / count).toFixed(2));
@@ -1,8 +1,4 @@
1
- function isPlainObject(value) {
2
- if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
3
- const prototype = Object.getPrototypeOf(value);
4
- return prototype === Object.prototype || prototype === null;
5
- }
1
+ const { isPlainObject } = require('./validation');
6
2
 
7
3
  function finiteRating(value, path) {
8
4
  if (!Number.isFinite(value) || value < 0) {
@@ -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 {
@@ -6,11 +6,16 @@ allowBuilds:
6
6
 
7
7
  minimumReleaseAgeExclude:
8
8
  - ws@8.21.0
9
- - hono@4.12.25
10
- - js-yaml@4.1.2
11
- - brace-expansion@5.0.8
9
+ - fast-uri@3.1.5
10
+ - hono@4.12.34
11
+ - ip-address@10.3.1
12
+ - js-yaml@4.3.1
13
+ - brace-expansion@5.0.9
12
14
  overrides:
13
- brace-expansion@<=5.0.7: ^5.0.8
15
+ brace-expansion: 5.0.9
14
16
  diff: 8.0.4
15
- ip-address: 10.2.0
17
+ fast-uri: 3.1.5
18
+ hono: 4.12.34
19
+ ip-address: 10.3.1
20
+ js-yaml: 4.3.1
16
21
  serialize-javascript: 7.0.5
package/schema.js CHANGED
@@ -122,31 +122,3 @@ function generateJsonSchema(example, descriptions = {}) {
122
122
  }
123
123
 
124
124
  module.exports = generateJsonSchema;
125
-
126
- // const example = {
127
- // name: 'Alice',
128
- // age: 30,
129
- // email: 'alice@example.com',
130
- // birthDate: '1990-01-01',
131
- // isAdmin: false,
132
- // preferences: {
133
- // theme: 'dark',
134
- // notifications: true
135
- // },
136
- // tags: ['admin', 'user']
137
- // };
138
-
139
- // const descriptions = {
140
- // name: 'Full name of the user',
141
- // age: 'Age must be 0 or greater',
142
- // email: 'User email address',
143
- // // birthDate: 'User birth date in YYYY-MM-DD format',
144
- // preferences: {
145
- // theme: 'Theme preference (light/dark)',
146
- // notifications: 'Whether notifications are enabled'
147
- // }
148
- // };
149
-
150
- // const schema = generateJsonSchema(example);
151
-
152
- // console.log(JSON.stringify(schema, null, 2));