modelmix 4.2.6 → 4.3.0

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.
package/index.js CHANGED
@@ -34,9 +34,9 @@ class ModelMix {
34
34
  this.config = {
35
35
  system: 'You are an assistant.',
36
36
  max_history: 1, // Default max history
37
- debug: false,
38
- verbose: 2, // 0=silent, 1=minimal, 2=readable summary, 3=full details
37
+ debug: 0, // 0=silent, 1=minimal, 2=readable summary, 3=full details
39
38
  bottleneck: defaultBottleneckConfig,
39
+ roundRobin: false, // false=fallback mode, true=round robin rotation
40
40
  ...config
41
41
  }
42
42
  const freeMix = { openrouter: true, cerebras: true, groq: true, together: false, lambda: false };
@@ -56,7 +56,9 @@ class ModelMix {
56
56
  }
57
57
 
58
58
  new({ options = {}, config = {}, mix = {} } = {}) {
59
- return new ModelMix({ options: { ...this.options, ...options }, config: { ...this.config, ...config }, mix: { ...this.mix, ...mix } });
59
+ const instance = new ModelMix({ options: { ...this.options, ...options }, config: { ...this.config, ...config }, mix: { ...this.mix, ...mix } });
60
+ instance.models = this.models; // Share models array for round-robin rotation
61
+ return instance;
60
62
  }
61
63
 
62
64
  static formatJSON(obj) {
@@ -79,24 +81,12 @@ class ModelMix {
79
81
  }
80
82
  }
81
83
 
82
- // Verbose logging helpers
84
+ // debug logging helpers
83
85
  static truncate(str, maxLen = 100) {
84
86
  if (!str || typeof str !== 'string') return str;
85
87
  return str.length > maxLen ? str.substring(0, maxLen) + '...' : str;
86
88
  }
87
89
 
88
- static getVerboseLevel(config) {
89
- // debug=true acts as verbose level 3
90
- return config.verbose || 0;
91
- }
92
-
93
- static verboseLog(level, config, ...args) {
94
- const verboseLevel = ModelMix.getVerboseLevel(config);
95
- if (verboseLevel >= level) {
96
- console.log(...args);
97
- }
98
- }
99
-
100
90
  static formatInputSummary(messages, system) {
101
91
  const lastMessage = messages[messages.length - 1];
102
92
  let inputText = '';
@@ -111,18 +101,18 @@ class ModelMix {
111
101
  const systemStr = `System: ${ModelMix.truncate(system, 50)}`;
112
102
  const inputStr = `Input: ${ModelMix.truncate(inputText, 120)}`;
113
103
  const msgCount = `(${messages.length} msg${messages.length !== 1 ? 's' : ''})`;
114
-
104
+
115
105
  return `${systemStr} \n| ${inputStr} ${msgCount}`;
116
106
  }
117
107
 
118
- static formatOutputSummary(result, verboseLevel = 2) {
108
+ static formatOutputSummary(result, debug) {
119
109
  const parts = [];
120
110
  if (result.message) {
121
111
  // Try to parse as JSON for better formatting
122
112
  try {
123
113
  const parsed = JSON.parse(result.message.trim());
124
- // If it's valid JSON and verbose >= 2, show it formatted
125
- if (verboseLevel >= 2) {
114
+ // If it's valid JSON and debug >= 2, show it formatted
115
+ if (debug >= 2) {
126
116
  parts.push(`Output (JSON):\n${ModelMix.formatJSON(parsed)}`);
127
117
  } else {
128
118
  parts.push(`Output: ${ModelMix.truncate(result.message, 150)}`);
@@ -133,7 +123,7 @@ class ModelMix {
133
123
  }
134
124
  }
135
125
  if (result.think) {
136
- parts.push(`Thinking: ${ModelMix.truncate(result.think, 80)}`);
126
+ parts.push(`Think: ${ModelMix.truncate(result.think, 80)}`);
137
127
  }
138
128
  if (result.toolCalls && result.toolCalls.length > 0) {
139
129
  const toolNames = result.toolCalls.map(t => t.function?.name || t.name).join(', ');
@@ -321,6 +311,14 @@ class ModelMix {
321
311
  return this;
322
312
  }
323
313
 
314
+ kimiK25think({ options = {}, config = {}, mix = { together: true } } = {}) {
315
+ mix = { ...this.mix, ...mix };
316
+ if (mix.together) this.attach('moonshotai/Kimi-K2.5', new MixTogether({ options, config }));
317
+ if (mix.fireworks) this.attach('accounts/fireworks/models/kimi-k2p5', new MixFireworks({ options, config }));
318
+ if (mix.openrouter) this.attach('moonshotai/kimi-k2.5', new MixOpenRouter({ options, config }));
319
+ return this;
320
+ }
321
+
324
322
  kimiK2think({ options = {}, config = {}, mix = { together: true } } = {}) {
325
323
  mix = { ...this.mix, ...mix };
326
324
  if (mix.together) this.attach('moonshotai/Kimi-K2-Thinking', new MixTogether({ options, config }));
@@ -328,8 +326,8 @@ class ModelMix {
328
326
  return this;
329
327
  }
330
328
 
331
- lmstudio({ options = {}, config = {} } = {}) {
332
- return this.attach('lmstudio', new MixLMStudio({ options, config }));
329
+ lmstudio(model = 'lmstudio', { options = {}, config = {} } = {}) {
330
+ return this.attach(model, new MixLMStudio({ options, config }));
333
331
  }
334
332
 
335
333
  minimaxM2({ options = {}, config = {} } = {}) {
@@ -562,7 +560,7 @@ class ModelMix {
562
560
 
563
561
  _extractBlock(response) {
564
562
  const block = response.match(/```(?:\w+)?\s*([\s\S]*?)```/);
565
- return block ? block[1].trim() : response;
563
+ return block ? block[1].trim() : response.trim();
566
564
  }
567
565
 
568
566
  async block({ addSystemExtra = true } = {}) {
@@ -610,7 +608,13 @@ class ModelMix {
610
608
 
611
609
  groupByRoles(messages) {
612
610
  return messages.reduce((acc, currentMessage, index) => {
613
- if (index === 0 || currentMessage.role !== messages[index - 1].role) {
611
+ // Don't group tool messages or assistant messages with tool_calls
612
+ // Each tool response must be separate with its own tool_call_id
613
+ const shouldNotGroup = currentMessage.role === 'tool' ||
614
+ currentMessage.tool_calls ||
615
+ currentMessage.tool_call_id;
616
+
617
+ if (index === 0 || currentMessage.role !== messages[index - 1].role || shouldNotGroup) {
614
618
  // acc.push({
615
619
  // role: currentMessage.role,
616
620
  // content: currentMessage.content
@@ -697,11 +701,23 @@ class ModelMix {
697
701
  throw new Error("No user messages have been added. Use addText(prompt), addTextFromFile(filePath), addImage(filePath), or addImageFromUrl(url) to add a prompt.");
698
702
  }
699
703
 
704
+ // Merge config to get final roundRobin value
705
+ const finalConfig = { ...this.config, ...config };
706
+
707
+ // Try all models in order (first is primary, rest are fallbacks)
708
+ const modelsToTry = this.models.map((model, index) => ({ model, index }));
709
+
710
+ // Round robin: rotate models array AFTER using current for next request
711
+ if (finalConfig.roundRobin && this.models.length > 1) {
712
+ const firstModel = this.models.shift();
713
+ this.models.push(firstModel);
714
+ }
715
+
700
716
  let lastError = null;
701
717
 
702
- for (let i = 0; i < this.models.length; i++) {
718
+ for (let i = 0; i < modelsToTry.length; i++) {
703
719
 
704
- const currentModel = this.models[i];
720
+ const { model: currentModel, index: originalIndex } = modelsToTry[i];
705
721
  const currentModelKey = currentModel.key;
706
722
  const providerInstance = currentModel.provider;
707
723
  const optionsTools = providerInstance.getOptionsTools(this.tools);
@@ -721,15 +737,17 @@ class ModelMix {
721
737
  ...config,
722
738
  };
723
739
 
724
- const verboseLevel = ModelMix.getVerboseLevel(currentConfig);
725
-
726
- if (verboseLevel >= 1) {
740
+ if (currentConfig.debug >= 1) {
727
741
  const isPrimary = i === 0;
728
742
  const prefix = isPrimary ? '→' : '↻';
729
- const suffix = isPrimary ? '' : ' (fallback)';
730
- const header = `\n${prefix} [${currentModelKey}] #${i + 1}${suffix}`;
731
-
732
- if (verboseLevel >= 2) {
743
+ const suffix = isPrimary
744
+ ? (currentConfig.roundRobin ? ` (round-robin #${originalIndex + 1})` : '')
745
+ : ' (fallback)';
746
+ // Extract provider name from class name (e.g., "MixOpenRouter" -> "openrouter")
747
+ const providerName = providerInstance.constructor.name.replace(/^Mix/, '').toLowerCase();
748
+ const header = `\n${prefix} [${providerName}:${currentModelKey}] #${originalIndex + 1}${suffix}`;
749
+
750
+ if (currentConfig.debug >= 2) {
733
751
  console.log(`${header} | ${ModelMix.formatInputSummary(this.messages, currentConfig.system)}`);
734
752
  } else {
735
753
  console.log(header);
@@ -761,24 +779,30 @@ class ModelMix {
761
779
 
762
780
  this.messages.push({ role: "assistant", content: null, tool_calls: result.toolCalls });
763
781
 
764
- const content = await this.processToolCalls(result.toolCalls);
765
- this.messages.push({ role: 'tool', content });
782
+ const toolResults = await this.processToolCalls(result.toolCalls);
783
+ for (const toolResult of toolResults) {
784
+ this.messages.push({
785
+ role: 'tool',
786
+ tool_call_id: toolResult.tool_call_id,
787
+ content: toolResult.content
788
+ });
789
+ }
766
790
 
767
791
  return this.execute();
768
792
  }
769
793
 
770
- // Verbose level 1: Just success indicator
771
- if (verboseLevel === 1) {
794
+ // debug level 1: Just success indicator
795
+ if (currentConfig.debug === 1) {
772
796
  console.log(`✓ Success`);
773
797
  }
774
798
 
775
- // Verbose level 2: Readable summary of output
776
- if (verboseLevel >= 2) {
777
- console.log(`✓ ${ModelMix.formatOutputSummary(result, verboseLevel).trim()}`);
799
+ // debug level 2: Readable summary of output
800
+ if (currentConfig.debug >= 2) {
801
+ console.log(`✓ ${ModelMix.formatOutputSummary(result, currentConfig.debug).trim()}`);
778
802
  }
779
803
 
780
- // Verbose level 3 (debug): Full response details
781
- if (verboseLevel >= 3) {
804
+ // debug level 3 (debug): Full response details
805
+ if (currentConfig.debug >= 3) {
782
806
  if (result.response) {
783
807
  console.log('\n[RAW RESPONSE]');
784
808
  console.log(ModelMix.formatJSON(result.response));
@@ -795,22 +819,22 @@ class ModelMix {
795
819
  }
796
820
  }
797
821
 
798
- if (verboseLevel >= 1) console.log('');
822
+ if (currentConfig.debug >= 1) console.log('');
799
823
 
800
824
  return result;
801
825
 
802
826
  } catch (error) {
803
827
  lastError = error;
804
- log.warn(`Model ${currentModelKey} failed (Attempt #${i + 1}/${this.models.length}).`);
828
+ log.warn(`Model ${currentModelKey} failed (Attempt #${i + 1}/${modelsToTry.length}).`);
805
829
  if (error.message) log.warn(`Error: ${error.message}`);
806
830
  if (error.statusCode) log.warn(`Status Code: ${error.statusCode}`);
807
831
  if (error.details) log.warn(`Details:\n${ModelMix.formatJSON(error.details)}`);
808
832
 
809
- if (i === this.models.length - 1) {
810
- console.error(`All ${this.models.length} model(s) failed. Throwing last error from ${currentModelKey}.`);
833
+ if (i === modelsToTry.length - 1) {
834
+ console.error(`All ${modelsToTry.length} model(s) failed. Throwing last error from ${currentModelKey}.`);
811
835
  throw lastError;
812
836
  } else {
813
- const nextModelKey = this.models[i + 1].key;
837
+ const nextModelKey = modelsToTry[i + 1].model.key;
814
838
  log.info(`-> Proceeding to next model: ${nextModelKey}`);
815
839
  }
816
840
  }
@@ -1024,16 +1048,13 @@ class MixCustom {
1024
1048
 
1025
1049
  options.messages = this.convertMessages(options.messages, config);
1026
1050
 
1027
- const verboseLevel = ModelMix.getVerboseLevel(config);
1028
-
1029
- // Verbose level 3 (debug): Full request details
1030
- if (verboseLevel >= 3) {
1051
+ // debug level 3 (debug): Full request details
1052
+ if (config.debug >= 3) {
1031
1053
  console.log('\n[REQUEST DETAILS]');
1032
1054
 
1033
1055
  console.log('\n[CONFIG]');
1034
1056
  const configToLog = { ...config };
1035
1057
  delete configToLog.debug;
1036
- delete configToLog.verbose;
1037
1058
  console.log(ModelMix.formatJSON(configToLog));
1038
1059
 
1039
1060
  console.log('\n[OPTIONS]');
@@ -1222,12 +1243,23 @@ class MixOpenAI extends MixCustom {
1222
1243
  }
1223
1244
 
1224
1245
  if (message.role === 'tool') {
1225
- for (const content of message.content) {
1246
+ // Handle new format: tool_call_id directly on message
1247
+ if (message.tool_call_id) {
1226
1248
  results.push({
1227
1249
  role: 'tool',
1228
- tool_call_id: content.tool_call_id,
1229
- content: content.content
1230
- })
1250
+ tool_call_id: message.tool_call_id,
1251
+ content: message.content
1252
+ });
1253
+ }
1254
+ // Handle old format: content is an array
1255
+ else if (Array.isArray(message.content)) {
1256
+ for (const content of message.content) {
1257
+ results.push({
1258
+ role: 'tool',
1259
+ tool_call_id: content.tool_call_id,
1260
+ content: content.content
1261
+ })
1262
+ }
1231
1263
  }
1232
1264
  continue;
1233
1265
  }
@@ -1255,10 +1287,10 @@ class MixOpenAI extends MixCustom {
1255
1287
 
1256
1288
  static getOptionsTools(tools) {
1257
1289
  const options = {};
1258
- options.tools = [];
1290
+ const toolsArray = [];
1259
1291
  for (const tool in tools) {
1260
1292
  for (const item of tools[tool]) {
1261
- options.tools.push({
1293
+ toolsArray.push({
1262
1294
  type: 'function',
1263
1295
  function: {
1264
1296
  name: item.name,
@@ -1269,7 +1301,11 @@ class MixOpenAI extends MixCustom {
1269
1301
  }
1270
1302
  }
1271
1303
 
1272
- // options.tool_choice = "auto";
1304
+ // Solo incluir tools si el array no está vacío
1305
+ if (toolsArray.length > 0) {
1306
+ options.tools = toolsArray;
1307
+ // options.tool_choice = "auto";
1308
+ }
1273
1309
 
1274
1310
  return options;
1275
1311
  }
@@ -1458,10 +1494,10 @@ class MixAnthropic extends MixCustom {
1458
1494
 
1459
1495
  static getOptionsTools(tools) {
1460
1496
  const options = {};
1461
- options.tools = [];
1497
+ const toolsArray = [];
1462
1498
  for (const tool in tools) {
1463
1499
  for (const item of tools[tool]) {
1464
- options.tools.push({
1500
+ toolsArray.push({
1465
1501
  name: item.name,
1466
1502
  description: item.description,
1467
1503
  input_schema: item.inputSchema
@@ -1469,6 +1505,11 @@ class MixAnthropic extends MixCustom {
1469
1505
  }
1470
1506
  }
1471
1507
 
1508
+ // Solo incluir tools si el array no está vacío
1509
+ if (toolsArray.length > 0) {
1510
+ options.tools = toolsArray;
1511
+ }
1512
+
1472
1513
  return options;
1473
1514
  }
1474
1515
  }
@@ -1855,16 +1896,13 @@ class MixGoogle extends MixCustom {
1855
1896
  };
1856
1897
 
1857
1898
  try {
1858
- const verboseLevel = ModelMix.getVerboseLevel(config);
1859
-
1860
- // Verbose level 3 (debug): Full request details
1861
- if (verboseLevel >= 3) {
1899
+ // debug level 3 (debug): Full request details
1900
+ if (config.debug >= 3) {
1862
1901
  console.log('\n[REQUEST DETAILS - GOOGLE]');
1863
1902
 
1864
1903
  console.log('\n[CONFIG]');
1865
1904
  const configToLog = { ...config };
1866
1905
  delete configToLog.debug;
1867
- delete configToLog.verbose;
1868
1906
  console.log(ModelMix.formatJSON(configToLog));
1869
1907
 
1870
1908
  console.log('\n[PAYLOAD]');
@@ -1925,11 +1963,14 @@ class MixGoogle extends MixCustom {
1925
1963
  }
1926
1964
  }
1927
1965
 
1928
- const options = {
1929
- tools: [{
1966
+ const options = {};
1967
+
1968
+ // Solo incluir tools si el array no está vacío
1969
+ if (functionDeclarations.length > 0) {
1970
+ options.tools = [{
1930
1971
  functionDeclarations
1931
- }]
1932
- };
1972
+ }];
1973
+ }
1933
1974
 
1934
1975
  return options;
1935
1976
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelmix",
3
- "version": "4.2.6",
3
+ "version": "4.3.0",
4
4
  "description": "🧬 Reliable interface with automatic fallback for AI LLMs.",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -46,7 +46,7 @@
46
46
  },
47
47
  "homepage": "https://github.com/clasen/ModelMix#readme",
48
48
  "dependencies": {
49
- "@modelcontextprotocol/sdk": "^1.25.2",
49
+ "@modelcontextprotocol/sdk": "^1.25.3",
50
50
  "axios": "^1.12.2",
51
51
  "bottleneck": "^2.19.5",
52
52
  "file-type": "^16.5.4",
@@ -56,7 +56,7 @@
56
56
  "devDependencies": {
57
57
  "chai": "^5.2.1",
58
58
  "dotenv": "^17.2.1",
59
- "mocha": "^11.7.1",
59
+ "mocha": "^11.3.0",
60
60
  "nock": "^14.0.9",
61
61
  "sinon": "^21.0.0"
62
62
  },