modelmix 5.0.2 → 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 (37) hide show
  1. package/README.md +109 -7
  2. package/RLM_PLUGIN_SPEC.md +465 -0
  3. package/demo/gemini.js +3 -4
  4. package/demo/short.js +1 -1
  5. package/effort.js +2 -0
  6. package/index.d.ts +61 -1
  7. package/index.js +333 -45
  8. package/package.json +7 -4
  9. package/plugins/rlm/index.d.ts +194 -0
  10. package/plugins/rlm/index.js +25 -0
  11. package/plugins/rlm/lib/budget.js +153 -0
  12. package/plugins/rlm/lib/isolated-vm-sandbox.js +90 -0
  13. package/plugins/rlm/lib/markdown.js +156 -0
  14. package/plugins/rlm/lib/planner-prompt.js +137 -0
  15. package/plugins/rlm/lib/plugin.js +203 -0
  16. package/plugins/rlm/lib/runtime.js +146 -0
  17. package/plugins/rlm/lib/variable-descriptors.js +228 -0
  18. package/plugins/rlm/lib/worker-catalog.js +70 -0
  19. package/plugins/rlm/package.json +32 -0
  20. package/plugins/rlm/prompts/partials/processing-rules.md +8 -0
  21. package/plugins/rlm/prompts/planner.md +53 -0
  22. package/plugins/rlm/test/budget.test.js +86 -0
  23. package/plugins/rlm/test/fixtures/book.md +24 -0
  24. package/plugins/rlm/test/isolated-vm-sandbox.test.js +114 -0
  25. package/plugins/rlm/test/markdown.test.js +64 -0
  26. package/plugins/rlm/test/planner-template.test.js +140 -0
  27. package/plugins/rlm/test/plugin-contract.test.js +182 -0
  28. package/plugins/rlm/test/rlm-e2e.test.js +338 -0
  29. package/plugins/rlm/test/variable-descriptors.test.js +170 -0
  30. package/plugins/rlm/test/worker-catalog.test.js +104 -0
  31. package/pnpm-workspace.yaml +6 -0
  32. package/skills/modelmix/SKILL.md +22 -3
  33. package/test/effort.test.js +14 -1
  34. package/test/live.mcp.js +6 -6
  35. package/test/live.test.js +2 -2
  36. package/test/plugins.test.js +356 -0
  37. 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) {
@@ -315,7 +344,8 @@ const MODEL_PRICING = {
315
344
  'gemini-3.1-pro-preview': { input: 2.00, output: 12.00 },
316
345
  'gemini-3-pro-preview': { input: 2.00, output: 12.00 },
317
346
  'gemini-3-flash-preview': { input: 0.50, output: 3.00 },
318
- '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 },
319
349
  'gemini-3.5-flash': { input: 0.75, output: 4.50 },
320
350
  'gemini-3.5-flash-lite': { input: 0.30, output: 2.50 },
321
351
  'gemini-2.5-pro': { input: 1.25, output: 10.00 },
@@ -380,6 +410,7 @@ class ModelMix {
380
410
  this.toolClient = {};
381
411
  this.mcp = {};
382
412
  this.mcpToolsManager = new MCPToolsManager();
413
+ this.plugins = [];
383
414
  this.templateFileAssignments = new Map();
384
415
  this.messageTemplates = new WeakMap();
385
416
  this.lastRaw = null;
@@ -452,6 +483,23 @@ class ModelMix {
452
483
  return this;
453
484
  }
454
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
+
455
503
  static new({ options = {}, config = {}, mix = {} } = {}) {
456
504
  return new ModelMix({ options, config, mix });
457
505
  }
@@ -467,6 +515,7 @@ class ModelMix {
467
515
  instance.systemTemplate = { ...this.systemTemplate };
468
516
  }
469
517
  instance.templateFileAssignments = new Map(this.templateFileAssignments);
518
+ instance.plugins = [...this.plugins];
470
519
  for (const key of Object.keys(config.templateData || {})) {
471
520
  instance.templateFileAssignments.delete(key);
472
521
  }
@@ -474,6 +523,102 @@ class ModelMix {
474
523
  return instance;
475
524
  }
476
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
+
477
622
  static formatJSON(obj) {
478
623
  return inspect(obj, {
479
624
  depth: null,
@@ -500,10 +645,11 @@ class ModelMix {
500
645
  return str.length > maxLen ? str.substring(0, maxLen) + '...' : str;
501
646
  }
502
647
 
503
- 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 } = {}) {
504
649
  const tokenCount = value => Number.isFinite(value) ? Math.max(0, value) : 0;
505
650
  const normalizedInput = tokenCount(input);
506
651
  const normalizedOutput = tokenCount(output);
652
+ const normalizedThinking = tokenCount(thinking);
507
653
  const normalizedCached = tokenCount(cached);
508
654
  const normalizedCacheWrite5m = tokenCount(cacheWrite5m);
509
655
  const normalizedCacheWrite1h = tokenCount(cacheWrite1h);
@@ -513,7 +659,7 @@ class ModelMix {
513
659
  );
514
660
  const normalizedTotal = Number.isFinite(total)
515
661
  ? Math.max(0, total)
516
- : normalizedInput + normalizedOutput;
662
+ : normalizedInput + normalizedOutput + normalizedThinking;
517
663
  const uncachedInput = Math.max(0, normalizedInput - normalizedCached - normalizedCacheWrite);
518
664
  const cacheHitRate = normalizedInput > 0
519
665
  ? Number((normalizedCached / normalizedInput).toFixed(4))
@@ -522,6 +668,7 @@ class ModelMix {
522
668
  return {
523
669
  input: normalizedInput,
524
670
  output: normalizedOutput,
671
+ thinking: normalizedThinking,
525
672
  total: normalizedTotal,
526
673
  cached: normalizedCached,
527
674
  cacheWrite: normalizedCacheWrite,
@@ -581,7 +728,9 @@ class ModelMix {
581
728
  cacheWrite: roundCost(genericCacheWriteCost + cacheWrite5mCost + cacheWrite1hCost),
582
729
  cacheWrite5m: cacheWrite5mCost,
583
730
  cacheWrite1h: cacheWrite1hCost,
584
- output: roundCost(normalized.output * outputPerMillion * outputMultiplier / 1_000_000)
731
+ output: roundCost(
732
+ (normalized.output + normalized.thinking) * outputPerMillion * outputMultiplier / 1_000_000
733
+ )
585
734
  };
586
735
  breakdown.total = roundCost(
587
736
  breakdown.uncachedInput
@@ -837,6 +986,9 @@ class ModelMix {
837
986
  gemini3flash({ options = {}, config = {} } = {}) {
838
987
  return this.attach('gemini-3-flash-preview', new MixGoogle({ options, config }));
839
988
  }
989
+ gemini37flash({ options = {}, config = {} } = {}) {
990
+ return this.attach('gemini-3.7-flash', new MixGoogle({ options, config }));
991
+ }
840
992
  gemini36flash({ options = {}, config = {} } = {}) {
841
993
  return this.attach('gemini-3.6-flash', new MixGoogle({ options, config }));
842
994
  }
@@ -1192,7 +1344,7 @@ class ModelMix {
1192
1344
  }
1193
1345
 
1194
1346
  async message() {
1195
- let raw = await this.execute({ options: { stream: false } });
1347
+ let raw = await this.execute({ options: { stream: false }, outputMode: 'message' });
1196
1348
  return raw.message;
1197
1349
  }
1198
1350
 
@@ -1228,7 +1380,7 @@ class ModelMix {
1228
1380
  systemSuffix += "\n\nOutput JSON Escape: double quotes, backslashes, and control characters inside JSON strings.\nEnsure the output contains no comments.";
1229
1381
  }
1230
1382
  }
1231
- const { message } = await this.execute({ options, config, systemSuffix });
1383
+ const { message } = await this.execute({ options, config, systemSuffix, outputMode: 'json' });
1232
1384
  const parsed = JSON.parse(this._extractBlock(message));
1233
1385
  return isArrayWrap ? parsed.out : parsed;
1234
1386
  }
@@ -1244,18 +1396,19 @@ class ModelMix {
1244
1396
  : '';
1245
1397
  const { message } = await this.execute({
1246
1398
  options: { stream: false },
1247
- systemSuffix
1399
+ systemSuffix,
1400
+ outputMode: 'block'
1248
1401
  });
1249
1402
  return this._extractBlock(message);
1250
1403
  }
1251
1404
 
1252
1405
  async raw() {
1253
- return this.execute({ options: { stream: false } });
1406
+ return this.execute({ options: { stream: false }, outputMode: 'raw' });
1254
1407
  }
1255
1408
 
1256
1409
  async stream(callback) {
1257
1410
  this.streamCallback = callback;
1258
- return this.execute({ options: { stream: true } });
1411
+ return this.execute({ options: { stream: true }, outputMode: 'stream' });
1259
1412
  }
1260
1413
 
1261
1414
  assignKeyFromFile(key, filePath) {
@@ -1474,22 +1627,25 @@ class ModelMix {
1474
1627
  return this.systemTemplate;
1475
1628
  }
1476
1629
 
1477
- async execute({ config = {}, options = {}, systemSuffix = '', _templateContext = null } = {}) {
1478
- if (!this.models || this.models.length === 0) {
1479
- throw new Error("No models specified. Use methods like .gpt5(), .sonnet46() first.");
1480
- }
1481
-
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
+ } = {}) {
1482
1640
  const isRootExecution = _templateContext === null;
1483
1641
  const templateContext = _templateContext || createTemplateRenderContext(() => this._choiceRandom());
1484
- const execution = this.limiter.schedule(async () => {
1485
- const preparedMessages = await this.prepareMessages(templateContext);
1486
1642
 
1643
+ if (!_pluginsApplied && this.plugins.length > 0) {
1644
+ const preparedMessages = await this.prepareMessages(templateContext);
1487
1645
  if (preparedMessages.length === 0) {
1488
1646
  throw new Error("No user messages have been added. Use addText(prompt), addTextFromFile(filePath), addImage(filePath), or addImageFromUrl(url) to add a prompt.");
1489
1647
  }
1490
-
1491
- // Merge config to get final roundRobin value and retry settings
1492
- const finalConfig = {
1648
+ const requestConfig = {
1493
1649
  ...this.config,
1494
1650
  ...config,
1495
1651
  retry: {
@@ -1497,6 +1653,101 @@ class ModelMix {
1497
1653
  ...(config.retry || {})
1498
1654
  }
1499
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
+ };
1500
1751
 
1501
1752
  // Try all models in order (first is primary, rest are fallbacks)
1502
1753
  const modelsToTry = this.models.map((model, index) => ({ model, index }));
@@ -1523,31 +1774,45 @@ class ModelMix {
1523
1774
  ...providerInstance.options,
1524
1775
  ...optionsTools,
1525
1776
  ...options,
1777
+ ...(_pluginRequest?.options || {}),
1526
1778
  model: currentModelKey
1527
1779
  };
1528
1780
 
1529
- const currentConfig = {
1530
- ...finalConfig,
1531
- ...providerInstance.config,
1532
- ...config,
1533
- retry: {
1534
- ...(finalConfig.retry || {}),
1535
- ...(providerInstance.config?.retry || {}),
1536
- ...(config.retry || {})
1781
+ const currentConfig = _pluginRequest
1782
+ ? {
1783
+ ...providerInstance.config,
1784
+ ..._pluginRequest.config,
1785
+ retry: {
1786
+ ...(providerInstance.config?.retry || {}),
1787
+ ...(_pluginRequest.config.retry || {})
1788
+ }
1537
1789
  }
1538
- };
1539
- const systemTemplate = this._resolveSystemTemplate(config, providerInstance.config);
1540
- const systemCacheKey = JSON.stringify([systemTemplate.filename, systemTemplate.source]);
1541
- if (!templateContext.renderedSystems.has(systemCacheKey)) {
1542
- templateContext.renderedSystems.set(
1543
- systemCacheKey,
1544
- this._renderTemplate(systemTemplate.source, {
1545
- filename: systemTemplate.filename,
1546
- label: 'system template'
1547
- }, templateContext)
1548
- );
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;
1549
1815
  }
1550
- currentConfig.system = templateContext.renderedSystems.get(systemCacheKey) + systemSuffix;
1551
1816
 
1552
1817
  // Grok 4.20 alias → reasoning / non-reasoning from unified effort
1553
1818
  const resolvedModelKey = resolveGrok420ModelKey(
@@ -1641,11 +1906,14 @@ class ModelMix {
1641
1906
  }
1642
1907
 
1643
1908
  if (result.toolCalls && result.toolCalls.length > 0) {
1909
+ const toolMessages = _pluginRequest
1910
+ ? clonePluginValue(_pluginRequest.messages)
1911
+ : this.messages;
1644
1912
  if (result.assistantMessage) {
1645
- this.messages.push(result.assistantMessage);
1913
+ toolMessages.push(result.assistantMessage);
1646
1914
  } else if (result.message) {
1647
1915
  if (result.signature) {
1648
- this.messages.push({
1916
+ toolMessages.push({
1649
1917
  role: "assistant", content: [{
1650
1918
  type: "thinking",
1651
1919
  // Empty string is valid (Anthropic display: "omitted").
@@ -1654,25 +1922,44 @@ class ModelMix {
1654
1922
  }]
1655
1923
  });
1656
1924
  } else {
1657
- this._addText(result.message, { role: "assistant" });
1925
+ toolMessages.push({
1926
+ role: 'assistant',
1927
+ content: [{ type: 'text', text: result.message }]
1928
+ });
1658
1929
  }
1659
1930
  }
1660
1931
 
1661
1932
  if (!result.assistantMessage) {
1662
- this.messages.push({ role: "assistant", content: null, tool_calls: result.toolCalls });
1933
+ toolMessages.push({ role: "assistant", content: null, tool_calls: result.toolCalls });
1663
1934
  }
1664
1935
 
1665
1936
  const toolResults = await this.processToolCalls(result.toolCalls);
1666
1937
  for (const toolResult of toolResults) {
1667
- this.messages.push({
1938
+ toolMessages.push({
1668
1939
  role: 'tool',
1669
1940
  tool_call_id: toolResult.tool_call_id,
1670
1941
  name: toolResult.name,
1671
1942
  content: toolResult.content
1672
1943
  });
1673
1944
  }
1945
+ this.messages = toolMessages;
1674
1946
 
1675
- 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
+ });
1676
1963
  }
1677
1964
 
1678
1965
  // debug level 1: Just success indicator
@@ -3680,6 +3967,7 @@ class MixGoogle extends MixCustom {
3680
3967
  return ModelMix.normalizeTokenUsage({
3681
3968
  input: data.usageMetadata.promptTokenCount || 0,
3682
3969
  output: data.usageMetadata.candidatesTokenCount || 0,
3970
+ thinking: data.usageMetadata.thoughtsTokenCount || 0,
3683
3971
  total: data.usageMetadata.totalTokenCount,
3684
3972
  cached: ModelMix.extractCacheTokens(data.usageMetadata),
3685
3973
  cacheWrite: ModelMix.extractCacheWriteTokens(data.usageMetadata)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelmix",
3
- "version": "5.0.2",
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
+ }