modelmix 5.0.1 → 5.0.3

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 (40) hide show
  1. package/README.md +111 -8
  2. package/RLM_PLUGIN_SPEC.md +465 -0
  3. package/demo/gemini.js +3 -4
  4. package/demo/grok.js +2 -2
  5. package/demo/images.js +2 -2
  6. package/demo/short.js +3 -3
  7. package/effort.js +3 -0
  8. package/index.d.ts +62 -1
  9. package/index.js +355 -49
  10. package/package.json +7 -4
  11. package/plugins/rlm/index.d.ts +194 -0
  12. package/plugins/rlm/index.js +25 -0
  13. package/plugins/rlm/lib/budget.js +153 -0
  14. package/plugins/rlm/lib/isolated-vm-sandbox.js +90 -0
  15. package/plugins/rlm/lib/markdown.js +156 -0
  16. package/plugins/rlm/lib/planner-prompt.js +137 -0
  17. package/plugins/rlm/lib/plugin.js +203 -0
  18. package/plugins/rlm/lib/runtime.js +146 -0
  19. package/plugins/rlm/lib/variable-descriptors.js +228 -0
  20. package/plugins/rlm/lib/worker-catalog.js +70 -0
  21. package/plugins/rlm/package.json +32 -0
  22. package/plugins/rlm/prompts/partials/processing-rules.md +8 -0
  23. package/plugins/rlm/prompts/planner.md +53 -0
  24. package/plugins/rlm/test/budget.test.js +86 -0
  25. package/plugins/rlm/test/fixtures/book.md +24 -0
  26. package/plugins/rlm/test/isolated-vm-sandbox.test.js +114 -0
  27. package/plugins/rlm/test/markdown.test.js +64 -0
  28. package/plugins/rlm/test/planner-template.test.js +140 -0
  29. package/plugins/rlm/test/plugin-contract.test.js +182 -0
  30. package/plugins/rlm/test/rlm-e2e.test.js +338 -0
  31. package/plugins/rlm/test/variable-descriptors.test.js +170 -0
  32. package/plugins/rlm/test/worker-catalog.test.js +104 -0
  33. package/pnpm-workspace.yaml +6 -0
  34. package/skills/modelmix/SKILL.md +23 -4
  35. package/test/effort.test.js +14 -1
  36. package/test/grok.test.js +74 -0
  37. package/test/live.mcp.js +8 -8
  38. package/test/live.test.js +9 -9
  39. package/test/plugins.test.js +356 -0
  40. package/test/tokens.test.js +37 -5
package/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const fs = require('fs');
2
+ const { randomUUID } = require('crypto');
2
3
  const ejs = require('ejs');
3
4
  const fileType = require('file-type');
4
5
  const detectFileTypeFromBuffer = fileType.fileTypeFromBuffer || fileType.fromBuffer;
@@ -46,6 +47,34 @@ function isPlainObject(value) {
46
47
  return prototype === Object.prototype || prototype === null;
47
48
  }
48
49
 
50
+ function clonePluginValue(value, seen = new WeakMap()) {
51
+ if (value === null || typeof value !== 'object') return value;
52
+ if (Buffer.isBuffer(value)) return Buffer.from(value);
53
+ if (seen.has(value)) return seen.get(value);
54
+
55
+ if (Array.isArray(value)) {
56
+ const clone = [];
57
+ seen.set(value, clone);
58
+ for (const item of value) clone.push(clonePluginValue(item, seen));
59
+ return clone;
60
+ }
61
+
62
+ if (!isPlainObject(value)) return value;
63
+ const clone = {};
64
+ seen.set(value, clone);
65
+ for (const [key, item] of Object.entries(value)) {
66
+ clone[key] = clonePluginValue(item, seen);
67
+ }
68
+ return clone;
69
+ }
70
+
71
+ function validatePluginResult(result, pluginName) {
72
+ if (!isPlainObject(result)) {
73
+ throw new TypeError(`Plugin "${pluginName}" must return a ModelMixResult object.`);
74
+ }
75
+ return result;
76
+ }
77
+
49
78
  function normalizeContentCache(cache) {
50
79
  if (cache !== undefined) {
51
80
  if (!isPlainObject(cache) || cache.breakpoint !== true) {
@@ -259,6 +288,21 @@ const GPT56_LONG_CONTEXT_PRICING = Object.freeze({
259
288
  outputMultiplier: 1.5
260
289
  });
261
290
 
291
+ const GROK46_LONG_CONTEXT_PRICING = Object.freeze({
292
+ inputThreshold: 200_000,
293
+ inputMultiplier: 2,
294
+ outputMultiplier: 2,
295
+ inclusive: true
296
+ });
297
+
298
+ function usesLongContextRates(pricing, inputTokens) {
299
+ const longContext = pricing.longContext;
300
+ if (!longContext) return false;
301
+ return longContext.inclusive
302
+ ? inputTokens >= longContext.inputThreshold
303
+ : inputTokens > longContext.inputThreshold;
304
+ }
305
+
262
306
  const MODEL_PRICING = {
263
307
  // OpenAI
264
308
  'gpt-realtime-mini': { input: 0.60, cachedInput: 0.06, output: 2.40 },
@@ -300,13 +344,15 @@ const MODEL_PRICING = {
300
344
  'gemini-3.1-pro-preview': { input: 2.00, output: 12.00 },
301
345
  'gemini-3-pro-preview': { input: 2.00, output: 12.00 },
302
346
  'gemini-3-flash-preview': { input: 0.50, output: 3.00 },
303
- 'gemini-3.6-flash': { input: 1.50, output: 7.50 },
347
+ 'gemini-3.7-flash': { input: 0.75, cachedInput: 0.075, output: 3.75 },
348
+ 'gemini-3.6-flash': { input: 0.75, cachedInput: 0.075, output: 3.75 },
304
349
  'gemini-3.5-flash': { input: 0.75, output: 4.50 },
305
350
  'gemini-3.5-flash-lite': { input: 0.30, output: 2.50 },
306
351
  'gemini-2.5-pro': { input: 1.25, output: 10.00 },
307
352
  'gemini-2.5-flash': { input: 0.30, output: 2.50 },
308
353
  'gemini-3.1-flash-lite-preview': { input: 0.25, output: 1.50 },
309
354
  // Grok
355
+ 'grok-4.6': { input: 2.00, cachedInput: 0.50, output: 6.00, longContext: GROK46_LONG_CONTEXT_PRICING },
310
356
  'grok-4.5': { input: 2.00, output: 6.00 },
311
357
  'grok-4.3': { input: 1.25, output: 2.50 },
312
358
  'grok-4.20-multi-agent-0309': { input: 1.25, output: 2.50 },
@@ -364,6 +410,7 @@ class ModelMix {
364
410
  this.toolClient = {};
365
411
  this.mcp = {};
366
412
  this.mcpToolsManager = new MCPToolsManager();
413
+ this.plugins = [];
367
414
  this.templateFileAssignments = new Map();
368
415
  this.messageTemplates = new WeakMap();
369
416
  this.lastRaw = null;
@@ -436,6 +483,23 @@ class ModelMix {
436
483
  return this;
437
484
  }
438
485
 
486
+ use(plugin) {
487
+ if (!isPlainObject(plugin)) {
488
+ throw new TypeError('plugin must be a plain object.');
489
+ }
490
+ if (typeof plugin.name !== 'string' || plugin.name.trim().length === 0) {
491
+ throw new TypeError('plugin.name must be a non-empty string.');
492
+ }
493
+ if (typeof plugin.execute !== 'function') {
494
+ throw new TypeError(`Plugin "${plugin.name}" must define execute(context, next).`);
495
+ }
496
+ if (this.plugins.some(current => current.name === plugin.name)) {
497
+ throw new Error(`Plugin "${plugin.name}" is already registered on this instance.`);
498
+ }
499
+ this.plugins.push(plugin);
500
+ return this;
501
+ }
502
+
439
503
  static new({ options = {}, config = {}, mix = {} } = {}) {
440
504
  return new ModelMix({ options, config, mix });
441
505
  }
@@ -451,6 +515,7 @@ class ModelMix {
451
515
  instance.systemTemplate = { ...this.systemTemplate };
452
516
  }
453
517
  instance.templateFileAssignments = new Map(this.templateFileAssignments);
518
+ instance.plugins = [...this.plugins];
454
519
  for (const key of Object.keys(config.templateData || {})) {
455
520
  instance.templateFileAssignments.delete(key);
456
521
  }
@@ -458,6 +523,102 @@ class ModelMix {
458
523
  return instance;
459
524
  }
460
525
 
526
+ _pluginsForPolicy(policy = 'inherit') {
527
+ if (policy === 'inherit') return [...this.plugins];
528
+ if (policy === 'none') return [];
529
+ if (!isPlainObject(policy)) {
530
+ throw new TypeError('plugins must be "inherit", "none", { include }, or { exclude }.');
531
+ }
532
+
533
+ const hasInclude = Object.prototype.hasOwnProperty.call(policy, 'include');
534
+ const hasExclude = Object.prototype.hasOwnProperty.call(policy, 'exclude');
535
+ if (hasInclude === hasExclude) {
536
+ throw new TypeError('plugins policy must define exactly one of include or exclude.');
537
+ }
538
+ const names = hasInclude ? policy.include : policy.exclude;
539
+ if (!Array.isArray(names) || names.some(name => typeof name !== 'string' || name.length === 0)) {
540
+ throw new TypeError('plugin include/exclude names must be non-empty strings.');
541
+ }
542
+ const uniqueNames = new Set(names);
543
+ const knownNames = new Set(this.plugins.map(plugin => plugin.name));
544
+ for (const name of uniqueNames) {
545
+ if (!knownNames.has(name)) {
546
+ throw new Error(`Plugin "${name}" is not registered on this instance.`);
547
+ }
548
+ }
549
+ return hasInclude
550
+ ? this.plugins.filter(plugin => uniqueNames.has(plugin.name))
551
+ : this.plugins.filter(plugin => !uniqueNames.has(plugin.name));
552
+ }
553
+
554
+ async _invokeChild(input, parentExecution) {
555
+ if (!isPlainObject(input)) {
556
+ throw new TypeError('Child invocation must be a plain object.');
557
+ }
558
+ if (input.history !== undefined && input.history !== false) {
559
+ throw new TypeError('Child invocations currently require history: false.');
560
+ }
561
+
562
+ const {
563
+ system,
564
+ systemFile,
565
+ assign,
566
+ messages = [],
567
+ tools = [],
568
+ options = {},
569
+ config = {},
570
+ mix = {},
571
+ model = this,
572
+ plugins = 'inherit',
573
+ outputMode = 'raw'
574
+ } = input;
575
+ if (!Array.isArray(messages)) {
576
+ throw new TypeError('Child invocation messages must be an array.');
577
+ }
578
+ if (system !== undefined && systemFile !== undefined) {
579
+ throw new TypeError('Child invocation must define only one of system or systemFile.');
580
+ }
581
+ if (systemFile !== undefined && (typeof systemFile !== 'string' || systemFile.length === 0)) {
582
+ throw new TypeError('Child invocation systemFile must be a non-empty string.');
583
+ }
584
+ if (assign !== undefined && !isPlainObject(assign)) {
585
+ throw new TypeError('Child invocation assign must be a plain object.');
586
+ }
587
+ if (!Array.isArray(tools)) {
588
+ throw new TypeError('Child invocation tools must be an array.');
589
+ }
590
+ if (!(model instanceof ModelMix)) {
591
+ throw new TypeError('Child invocation model must be a ModelMix instance.');
592
+ }
593
+
594
+ const child = model === this
595
+ ? ModelMix.new({ options, config, mix })
596
+ : model.new({ options, config, mix });
597
+ child.models = model.models;
598
+ child.plugins = this._pluginsForPolicy(plugins);
599
+ if (assign !== undefined) child.assign(assign);
600
+ if (system !== undefined) child.setSystem(system);
601
+ if (systemFile !== undefined) child.setSystemFromFile(systemFile);
602
+ child.messages = clonePluginValue(messages);
603
+ for (const tool of tools) {
604
+ if (!isPlainObject(tool) || !isPlainObject(tool.tool) || typeof tool.callback !== 'function') {
605
+ throw new TypeError('Child invocation tools must contain { tool, callback }.');
606
+ }
607
+ child.addTool(tool.tool, tool.callback);
608
+ }
609
+
610
+ const execution = {
611
+ executionId: randomUUID(),
612
+ parentExecutionId: parentExecution.executionId,
613
+ depth: parentExecution.depth + 1
614
+ };
615
+ const result = await child.execute({
616
+ outputMode,
617
+ _executionMetadata: execution
618
+ });
619
+ return { ...result, execution };
620
+ }
621
+
461
622
  static formatJSON(obj) {
462
623
  return inspect(obj, {
463
624
  depth: null,
@@ -484,10 +645,11 @@ class ModelMix {
484
645
  return str.length > maxLen ? str.substring(0, maxLen) + '...' : str;
485
646
  }
486
647
 
487
- static normalizeTokenUsage({ input = 0, output = 0, total, cached = 0, cacheWrite = 0, cacheWrite5m = 0, cacheWrite1h = 0 } = {}) {
648
+ static normalizeTokenUsage({ input = 0, output = 0, thinking = 0, total, cached = 0, cacheWrite = 0, cacheWrite5m = 0, cacheWrite1h = 0 } = {}) {
488
649
  const tokenCount = value => Number.isFinite(value) ? Math.max(0, value) : 0;
489
650
  const normalizedInput = tokenCount(input);
490
651
  const normalizedOutput = tokenCount(output);
652
+ const normalizedThinking = tokenCount(thinking);
491
653
  const normalizedCached = tokenCount(cached);
492
654
  const normalizedCacheWrite5m = tokenCount(cacheWrite5m);
493
655
  const normalizedCacheWrite1h = tokenCount(cacheWrite1h);
@@ -497,7 +659,7 @@ class ModelMix {
497
659
  );
498
660
  const normalizedTotal = Number.isFinite(total)
499
661
  ? Math.max(0, total)
500
- : normalizedInput + normalizedOutput;
662
+ : normalizedInput + normalizedOutput + normalizedThinking;
501
663
  const uncachedInput = Math.max(0, normalizedInput - normalizedCached - normalizedCacheWrite);
502
664
  const cacheHitRate = normalizedInput > 0
503
665
  ? Number((normalizedCached / normalizedInput).toFixed(4))
@@ -506,6 +668,7 @@ class ModelMix {
506
668
  return {
507
669
  input: normalizedInput,
508
670
  output: normalizedOutput,
671
+ thinking: normalizedThinking,
509
672
  total: normalizedTotal,
510
673
  cached: normalizedCached,
511
674
  cacheWrite: normalizedCacheWrite,
@@ -535,7 +698,7 @@ class ModelMix {
535
698
 
536
699
  const normalized = ModelMix.normalizeTokenUsage(tokens);
537
700
  const longContext = pricing.longContext;
538
- const useLongContextRates = longContext && normalized.input > longContext.inputThreshold;
701
+ const useLongContextRates = usesLongContextRates(pricing, normalized.input);
539
702
  const inputMultiplier = useLongContextRates ? longContext.inputMultiplier : 1;
540
703
  const outputMultiplier = useLongContextRates ? longContext.outputMultiplier : 1;
541
704
  const {
@@ -565,7 +728,9 @@ class ModelMix {
565
728
  cacheWrite: roundCost(genericCacheWriteCost + cacheWrite5mCost + cacheWrite1hCost),
566
729
  cacheWrite5m: cacheWrite5mCost,
567
730
  cacheWrite1h: cacheWrite1hCost,
568
- output: roundCost(normalized.output * outputPerMillion * outputMultiplier / 1_000_000)
731
+ output: roundCost(
732
+ (normalized.output + normalized.thinking) * outputPerMillion * outputMultiplier / 1_000_000
733
+ )
569
734
  };
570
735
  breakdown.total = roundCost(
571
736
  breakdown.uncachedInput
@@ -587,9 +752,8 @@ class ModelMix {
587
752
 
588
753
  const normalized = ModelMix.normalizeTokenUsage(tokens);
589
754
  const longContext = pricing.longContext;
590
- const inputMultiplier = longContext && normalized.input > longContext.inputThreshold
591
- ? longContext.inputMultiplier
592
- : 1;
755
+ const useLongContextRates = usesLongContextRates(pricing, normalized.input);
756
+ const inputMultiplier = useLongContextRates ? longContext.inputMultiplier : 1;
593
757
  const cachedInputPerMillion = pricing.cachedInput ?? pricing.input;
594
758
  const cacheWritePerMillion = pricing.cacheWrite ?? pricing.input;
595
759
  const cacheWrite1hPerMillion = pricing.cacheWrite1h ?? cacheWritePerMillion;
@@ -822,6 +986,9 @@ class ModelMix {
822
986
  gemini3flash({ options = {}, config = {} } = {}) {
823
987
  return this.attach('gemini-3-flash-preview', new MixGoogle({ options, config }));
824
988
  }
989
+ gemini37flash({ options = {}, config = {} } = {}) {
990
+ return this.attach('gemini-3.7-flash', new MixGoogle({ options, config }));
991
+ }
825
992
  gemini36flash({ options = {}, config = {} } = {}) {
826
993
  return this.attach('gemini-3.6-flash', new MixGoogle({ options, config }));
827
994
  }
@@ -844,6 +1011,9 @@ class ModelMix {
844
1011
  return this.attach('sonar', new MixPerplexity({ options, config }));
845
1012
  }
846
1013
 
1014
+ grok46({ options = {}, config = {} } = {}) {
1015
+ return this.attach('grok-4.6', new MixGrok({ options, config }));
1016
+ }
847
1017
  grok45({ options = {}, config = {} } = {}) {
848
1018
  return this.attach('grok-4.5', new MixGrok({ options, config }));
849
1019
  }
@@ -1174,7 +1344,7 @@ class ModelMix {
1174
1344
  }
1175
1345
 
1176
1346
  async message() {
1177
- let raw = await this.execute({ options: { stream: false } });
1347
+ let raw = await this.execute({ options: { stream: false }, outputMode: 'message' });
1178
1348
  return raw.message;
1179
1349
  }
1180
1350
 
@@ -1210,7 +1380,7 @@ class ModelMix {
1210
1380
  systemSuffix += "\n\nOutput JSON Escape: double quotes, backslashes, and control characters inside JSON strings.\nEnsure the output contains no comments.";
1211
1381
  }
1212
1382
  }
1213
- const { message } = await this.execute({ options, config, systemSuffix });
1383
+ const { message } = await this.execute({ options, config, systemSuffix, outputMode: 'json' });
1214
1384
  const parsed = JSON.parse(this._extractBlock(message));
1215
1385
  return isArrayWrap ? parsed.out : parsed;
1216
1386
  }
@@ -1226,18 +1396,19 @@ class ModelMix {
1226
1396
  : '';
1227
1397
  const { message } = await this.execute({
1228
1398
  options: { stream: false },
1229
- systemSuffix
1399
+ systemSuffix,
1400
+ outputMode: 'block'
1230
1401
  });
1231
1402
  return this._extractBlock(message);
1232
1403
  }
1233
1404
 
1234
1405
  async raw() {
1235
- return this.execute({ options: { stream: false } });
1406
+ return this.execute({ options: { stream: false }, outputMode: 'raw' });
1236
1407
  }
1237
1408
 
1238
1409
  async stream(callback) {
1239
1410
  this.streamCallback = callback;
1240
- return this.execute({ options: { stream: true } });
1411
+ return this.execute({ options: { stream: true }, outputMode: 'stream' });
1241
1412
  }
1242
1413
 
1243
1414
  assignKeyFromFile(key, filePath) {
@@ -1456,22 +1627,25 @@ class ModelMix {
1456
1627
  return this.systemTemplate;
1457
1628
  }
1458
1629
 
1459
- async execute({ config = {}, options = {}, systemSuffix = '', _templateContext = null } = {}) {
1460
- if (!this.models || this.models.length === 0) {
1461
- throw new Error("No models specified. Use methods like .gpt5(), .sonnet46() first.");
1462
- }
1463
-
1630
+ async execute({
1631
+ config = {},
1632
+ options = {},
1633
+ systemSuffix = '',
1634
+ outputMode = 'raw',
1635
+ _templateContext = null,
1636
+ _pluginRequest = null,
1637
+ _executionMetadata = null,
1638
+ _pluginsApplied = false
1639
+ } = {}) {
1464
1640
  const isRootExecution = _templateContext === null;
1465
1641
  const templateContext = _templateContext || createTemplateRenderContext(() => this._choiceRandom());
1466
- const execution = this.limiter.schedule(async () => {
1467
- const preparedMessages = await this.prepareMessages(templateContext);
1468
1642
 
1643
+ if (!_pluginsApplied && this.plugins.length > 0) {
1644
+ const preparedMessages = await this.prepareMessages(templateContext);
1469
1645
  if (preparedMessages.length === 0) {
1470
1646
  throw new Error("No user messages have been added. Use addText(prompt), addTextFromFile(filePath), addImage(filePath), or addImageFromUrl(url) to add a prompt.");
1471
1647
  }
1472
-
1473
- // Merge config to get final roundRobin value and retry settings
1474
- const finalConfig = {
1648
+ const requestConfig = {
1475
1649
  ...this.config,
1476
1650
  ...config,
1477
1651
  retry: {
@@ -1479,6 +1653,101 @@ class ModelMix {
1479
1653
  ...(config.retry || {})
1480
1654
  }
1481
1655
  };
1656
+ const systemTemplate = this._resolveSystemTemplate(config, {});
1657
+ const systemCacheKey = JSON.stringify([systemTemplate.filename, systemTemplate.source]);
1658
+ if (!templateContext.renderedSystems.has(systemCacheKey)) {
1659
+ templateContext.renderedSystems.set(
1660
+ systemCacheKey,
1661
+ this._renderTemplate(systemTemplate.source, {
1662
+ filename: systemTemplate.filename,
1663
+ label: 'system template'
1664
+ }, templateContext)
1665
+ );
1666
+ }
1667
+ const request = {
1668
+ system: templateContext.renderedSystems.get(systemCacheKey) + systemSuffix,
1669
+ messages: clonePluginValue(preparedMessages),
1670
+ options: clonePluginValue({ ...this.options, ...options }),
1671
+ config: clonePluginValue(requestConfig),
1672
+ outputMode
1673
+ };
1674
+ const executionMetadata = _executionMetadata || {
1675
+ executionId: randomUUID(),
1676
+ parentExecutionId: null,
1677
+ depth: 0
1678
+ };
1679
+ let providerInvoked = false;
1680
+
1681
+ const dispatch = async index => {
1682
+ if (index === this.plugins.length) {
1683
+ providerInvoked = true;
1684
+ return this.execute({
1685
+ config,
1686
+ options,
1687
+ systemSuffix,
1688
+ outputMode,
1689
+ _templateContext: templateContext,
1690
+ _pluginRequest: request,
1691
+ _executionMetadata: executionMetadata,
1692
+ _pluginsApplied: true
1693
+ });
1694
+ }
1695
+
1696
+ const plugin = this.plugins[index];
1697
+ let nextCalled = false;
1698
+ const next = () => {
1699
+ if (nextCalled) {
1700
+ throw new Error(`Plugin "${plugin.name}" called next() multiple times.`);
1701
+ }
1702
+ nextCalled = true;
1703
+ return dispatch(index + 1);
1704
+ };
1705
+ const context = {
1706
+ request,
1707
+ execution: Object.freeze({ ...executionMetadata }),
1708
+ invoke: input => this._invokeChild(input, executionMetadata)
1709
+ };
1710
+ const result = await plugin.execute(context, next);
1711
+ return validatePluginResult(result, plugin.name);
1712
+ };
1713
+
1714
+ const result = await dispatch(0);
1715
+ this.lastRaw = result;
1716
+ if (!providerInvoked) {
1717
+ if (this.config.max_history === 0) {
1718
+ this.messages = [];
1719
+ } else if (result.message) {
1720
+ this._addText(result.message, { role: 'assistant' });
1721
+ }
1722
+ }
1723
+ if (isRootExecution) this._commitTemplateRenderContext(templateContext);
1724
+ return result;
1725
+ }
1726
+
1727
+ if (!this.models || this.models.length === 0) {
1728
+ throw new Error("No models specified. Use methods like .gpt5(), .sonnet46() first.");
1729
+ }
1730
+
1731
+ const execution = this.limiter.schedule(async () => {
1732
+ const preparedMessages = _pluginRequest
1733
+ ? _pluginRequest.messages
1734
+ : await this.prepareMessages(templateContext);
1735
+
1736
+ if (preparedMessages.length === 0) {
1737
+ throw new Error("No user messages have been added. Use addText(prompt), addTextFromFile(filePath), addImage(filePath), or addImageFromUrl(url) to add a prompt.");
1738
+ }
1739
+
1740
+ // Merge config to get final roundRobin value and retry settings
1741
+ const finalConfig = _pluginRequest
1742
+ ? _pluginRequest.config
1743
+ : {
1744
+ ...this.config,
1745
+ ...config,
1746
+ retry: {
1747
+ ...(this.config.retry || {}),
1748
+ ...(config.retry || {})
1749
+ }
1750
+ };
1482
1751
 
1483
1752
  // Try all models in order (first is primary, rest are fallbacks)
1484
1753
  const modelsToTry = this.models.map((model, index) => ({ model, index }));
@@ -1505,31 +1774,45 @@ class ModelMix {
1505
1774
  ...providerInstance.options,
1506
1775
  ...optionsTools,
1507
1776
  ...options,
1777
+ ...(_pluginRequest?.options || {}),
1508
1778
  model: currentModelKey
1509
1779
  };
1510
1780
 
1511
- const currentConfig = {
1512
- ...finalConfig,
1513
- ...providerInstance.config,
1514
- ...config,
1515
- retry: {
1516
- ...(finalConfig.retry || {}),
1517
- ...(providerInstance.config?.retry || {}),
1518
- ...(config.retry || {})
1781
+ const currentConfig = _pluginRequest
1782
+ ? {
1783
+ ...providerInstance.config,
1784
+ ..._pluginRequest.config,
1785
+ retry: {
1786
+ ...(providerInstance.config?.retry || {}),
1787
+ ...(_pluginRequest.config.retry || {})
1788
+ }
1519
1789
  }
1520
- };
1521
- const systemTemplate = this._resolveSystemTemplate(config, providerInstance.config);
1522
- const systemCacheKey = JSON.stringify([systemTemplate.filename, systemTemplate.source]);
1523
- if (!templateContext.renderedSystems.has(systemCacheKey)) {
1524
- templateContext.renderedSystems.set(
1525
- systemCacheKey,
1526
- this._renderTemplate(systemTemplate.source, {
1527
- filename: systemTemplate.filename,
1528
- label: 'system template'
1529
- }, templateContext)
1530
- );
1790
+ : {
1791
+ ...finalConfig,
1792
+ ...providerInstance.config,
1793
+ ...config,
1794
+ retry: {
1795
+ ...(finalConfig.retry || {}),
1796
+ ...(providerInstance.config?.retry || {}),
1797
+ ...(config.retry || {})
1798
+ }
1799
+ };
1800
+ if (_pluginRequest) {
1801
+ currentConfig.system = _pluginRequest.system;
1802
+ } else {
1803
+ const systemTemplate = this._resolveSystemTemplate(config, providerInstance.config);
1804
+ const systemCacheKey = JSON.stringify([systemTemplate.filename, systemTemplate.source]);
1805
+ if (!templateContext.renderedSystems.has(systemCacheKey)) {
1806
+ templateContext.renderedSystems.set(
1807
+ systemCacheKey,
1808
+ this._renderTemplate(systemTemplate.source, {
1809
+ filename: systemTemplate.filename,
1810
+ label: 'system template'
1811
+ }, templateContext)
1812
+ );
1813
+ }
1814
+ currentConfig.system = templateContext.renderedSystems.get(systemCacheKey) + systemSuffix;
1531
1815
  }
1532
- currentConfig.system = templateContext.renderedSystems.get(systemCacheKey) + systemSuffix;
1533
1816
 
1534
1817
  // Grok 4.20 alias → reasoning / non-reasoning from unified effort
1535
1818
  const resolvedModelKey = resolveGrok420ModelKey(
@@ -1623,11 +1906,14 @@ class ModelMix {
1623
1906
  }
1624
1907
 
1625
1908
  if (result.toolCalls && result.toolCalls.length > 0) {
1909
+ const toolMessages = _pluginRequest
1910
+ ? clonePluginValue(_pluginRequest.messages)
1911
+ : this.messages;
1626
1912
  if (result.assistantMessage) {
1627
- this.messages.push(result.assistantMessage);
1913
+ toolMessages.push(result.assistantMessage);
1628
1914
  } else if (result.message) {
1629
1915
  if (result.signature) {
1630
- this.messages.push({
1916
+ toolMessages.push({
1631
1917
  role: "assistant", content: [{
1632
1918
  type: "thinking",
1633
1919
  // Empty string is valid (Anthropic display: "omitted").
@@ -1636,25 +1922,44 @@ class ModelMix {
1636
1922
  }]
1637
1923
  });
1638
1924
  } else {
1639
- this._addText(result.message, { role: "assistant" });
1925
+ toolMessages.push({
1926
+ role: 'assistant',
1927
+ content: [{ type: 'text', text: result.message }]
1928
+ });
1640
1929
  }
1641
1930
  }
1642
1931
 
1643
1932
  if (!result.assistantMessage) {
1644
- this.messages.push({ role: "assistant", content: null, tool_calls: result.toolCalls });
1933
+ toolMessages.push({ role: "assistant", content: null, tool_calls: result.toolCalls });
1645
1934
  }
1646
1935
 
1647
1936
  const toolResults = await this.processToolCalls(result.toolCalls);
1648
1937
  for (const toolResult of toolResults) {
1649
- this.messages.push({
1938
+ toolMessages.push({
1650
1939
  role: 'tool',
1651
1940
  tool_call_id: toolResult.tool_call_id,
1652
1941
  name: toolResult.name,
1653
1942
  content: toolResult.content
1654
1943
  });
1655
1944
  }
1945
+ this.messages = toolMessages;
1656
1946
 
1657
- return this.execute({ options, config, systemSuffix, _templateContext: templateContext });
1947
+ const nextPluginRequest = _pluginRequest
1948
+ ? {
1949
+ ..._pluginRequest,
1950
+ messages: toolMessages
1951
+ }
1952
+ : null;
1953
+ return this.execute({
1954
+ options,
1955
+ config,
1956
+ systemSuffix,
1957
+ outputMode,
1958
+ _templateContext: templateContext,
1959
+ _pluginRequest: nextPluginRequest,
1960
+ _executionMetadata,
1961
+ _pluginsApplied
1962
+ });
1658
1963
  }
1659
1964
 
1660
1965
  // debug level 1: Just success indicator
@@ -3662,6 +3967,7 @@ class MixGoogle extends MixCustom {
3662
3967
  return ModelMix.normalizeTokenUsage({
3663
3968
  input: data.usageMetadata.promptTokenCount || 0,
3664
3969
  output: data.usageMetadata.candidatesTokenCount || 0,
3970
+ thinking: data.usageMetadata.thoughtsTokenCount || 0,
3665
3971
  total: data.usageMetadata.totalTokenCount,
3666
3972
  cached: ModelMix.extractCacheTokens(data.usageMetadata),
3667
3973
  cacheWrite: ModelMix.extractCacheWriteTokens(data.usageMetadata)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelmix",
3
- "version": "5.0.1",
3
+ "version": "5.0.3",
4
4
  "description": "🧬 Reliable interface with automatic fallback for AI LLMs.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -75,6 +75,9 @@
75
75
  "test:live": "mocha test/live.test.js --timeout 10000 --require test/setup.js",
76
76
  "test:live.mcp": "mocha test/live.mcp.js --timeout 60000 --require test/setup.js",
77
77
  "test:tokens": "mocha test/tokens.test.js --timeout 10000 --require test/setup.js",
78
- "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 --timeout 10000 --require test/setup.js"
79
- }
80
- }
78
+ "test:plugins": "mocha test/plugins.test.js --timeout 10000 --require test/setup.js",
79
+ "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/plugins.test.js plugins/rlm/test/**/*.test.js --timeout 10000 --require test/setup.js"
81
+ },
82
+ "packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c"
83
+ }