modelmix 4.6.6 → 4.6.7

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/demo/opus5.js ADDED
@@ -0,0 +1,23 @@
1
+ import { ModelMix } from '../index.js';
2
+ try { process.loadEnvFile(); } catch {}
3
+
4
+
5
+ const mmix = new ModelMix({
6
+ config: {
7
+ debug: 3,
8
+ }
9
+ });
10
+
11
+ console.log("\n" + '--------| opus5() |--------');
12
+
13
+ const opus = mmix.opus5();
14
+ opus.addText("Explain quantum entanglement in simple terms.");
15
+ const response = await opus.message();
16
+ console.log(response);
17
+
18
+ console.log("\n" + '--------| opus5think() |--------');
19
+
20
+ const opusThink = mmix.new().opus5think();
21
+ opusThink.addText("A bat and a ball cost $1.10 in total. The bat costs $1.00 more than the ball. How much does the ball cost?");
22
+ const thinkResponse = await opusThink.raw();
23
+ console.log(thinkResponse);
package/index.js CHANGED
@@ -887,13 +887,21 @@ class ModelMix {
887
887
  return input;
888
888
  }
889
889
 
890
+ static hasToolInteraction(message) {
891
+ if (!message) return false;
892
+ if (message.role === 'tool' || message.tool_calls || message.tool_call_id) return true;
893
+ // Anthropic-native assistant turns store tool_use blocks in content (no tool_calls).
894
+ if (message.role === 'assistant' && Array.isArray(message.content)) {
895
+ return message.content.some(block => block?.type === 'tool_use');
896
+ }
897
+ return false;
898
+ }
899
+
890
900
  groupByRoles(messages) {
891
901
  return messages.reduce((acc, currentMessage, index) => {
892
902
  // Don't group tool messages or assistant messages with tool_calls
893
903
  // Each tool response must be separate with its own tool_call_id
894
- const shouldNotGroup = currentMessage.role === 'tool' ||
895
- currentMessage.tool_calls ||
896
- currentMessage.tool_call_id;
904
+ const shouldNotGroup = ModelMix.hasToolInteraction(currentMessage);
897
905
 
898
906
  if (index === 0 || currentMessage.role !== messages[index - 1].role || shouldNotGroup) {
899
907
  // acc.push({
@@ -939,7 +947,7 @@ class ModelMix {
939
947
  // backtrack to include the full sequence (user → assistant/tool_calls → tool results)
940
948
  while (sliceStart > 0 && sliceStart < this.messages.length) {
941
949
  const msg = this.messages[sliceStart];
942
- if (msg.role === 'tool' || (msg.role === 'assistant' && msg.tool_calls)) {
950
+ if (ModelMix.hasToolInteraction(msg)) {
943
951
  sliceStart--;
944
952
  } else {
945
953
  break;
@@ -1108,7 +1116,8 @@ class ModelMix {
1108
1116
  this.messages.push({
1109
1117
  role: "assistant", content: [{
1110
1118
  type: "thinking",
1111
- thinking: result.think,
1119
+ // Empty string is valid (Anthropic display: "omitted").
1120
+ thinking: result.think ?? '',
1112
1121
  signature: result.signature
1113
1122
  }]
1114
1123
  });
@@ -1184,7 +1193,8 @@ class ModelMix {
1184
1193
  this.messages.push({
1185
1194
  role: "assistant", content: [{
1186
1195
  type: "thinking",
1187
- thinking: result.think,
1196
+ // Empty string is valid (Anthropic display: "omitted").
1197
+ thinking: result.think ?? '',
1188
1198
  signature: result.signature
1189
1199
  }, {
1190
1200
  type: "text",
@@ -2229,10 +2239,10 @@ class MixAnthropic extends MixCustom {
2229
2239
  const filteredMessages = [];
2230
2240
  for (let i = 0; i < messages.length; i++) {
2231
2241
  if (messages[i].role === 'tool') {
2232
- // Check if there's a preceding assistant message with tool_calls
2242
+ // Preceding assistant may use OpenAI tool_calls or Anthropic tool_use blocks.
2233
2243
  let foundToolCall = false;
2234
2244
  for (let j = i - 1; j >= 0; j--) {
2235
- if (messages[j].role === 'assistant' && messages[j].tool_calls) {
2245
+ if (ModelMix.hasToolInteraction(messages[j]) && messages[j].role === 'assistant') {
2236
2246
  foundToolCall = true;
2237
2247
  break;
2238
2248
  }
@@ -2356,12 +2366,22 @@ class MixAnthropic extends MixCustom {
2356
2366
  throw new Error(`Anthropic content blocks are missing .text (stop_reason: ${stopReason ?? 'unknown'}, content_types: ${contentTypes}).`);
2357
2367
  }
2358
2368
 
2369
+ static extractThinkingBlock(data) {
2370
+ const content = Array.isArray(data?.content) ? data.content : [];
2371
+ return content.find(block => block?.type === 'thinking') || null;
2372
+ }
2373
+
2359
2374
  static extractThink(data) {
2360
- return data.content[0]?.thinking || null;
2375
+ const block = MixAnthropic.extractThinkingBlock(data);
2376
+ // Preserve empty string: display "omitted" returns thinking: "" with a signature.
2377
+ return typeof block?.thinking === 'string' ? block.thinking : null;
2361
2378
  }
2362
2379
 
2363
2380
  static extractSignature(data) {
2364
- return data.content[0]?.signature || null;
2381
+ const block = MixAnthropic.extractThinkingBlock(data);
2382
+ return typeof block?.signature === 'string' && block.signature
2383
+ ? block.signature
2384
+ : null;
2365
2385
  }
2366
2386
 
2367
2387
  static extractTokens(data) {
@@ -2383,13 +2403,18 @@ class MixAnthropic extends MixCustom {
2383
2403
  }
2384
2404
 
2385
2405
  processResponse(response) {
2406
+ const data = response.data;
2386
2407
  return {
2387
- message: MixAnthropic.extractMessage(response.data),
2388
- think: MixAnthropic.extractThink(response.data),
2389
- toolCalls: MixAnthropic.extractToolCalls(response.data),
2390
- tokens: MixAnthropic.extractTokens(response.data),
2391
- response: response.data,
2392
- signature: MixAnthropic.extractSignature(response.data)
2408
+ message: MixAnthropic.extractMessage(data),
2409
+ think: MixAnthropic.extractThink(data),
2410
+ toolCalls: MixAnthropic.extractToolCalls(data),
2411
+ tokens: MixAnthropic.extractTokens(data),
2412
+ response: data,
2413
+ signature: MixAnthropic.extractSignature(data),
2414
+ // Replay Anthropic content blocks verbatim (including empty thinking).
2415
+ assistantMessage: Array.isArray(data?.content)
2416
+ ? { role: 'assistant', content: data.content }
2417
+ : undefined
2393
2418
  }
2394
2419
  }
2395
2420
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelmix",
3
- "version": "4.6.6",
3
+ "version": "4.6.7",
4
4
  "description": "🧬 Reliable interface with automatic fallback for AI LLMs.",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -81,4 +81,110 @@ describe('Anthropic Model Registration Tests', () => {
81
81
  budget_tokens: 1638
82
82
  });
83
83
  });
84
+
85
+ describe('Thinking block extraction', () => {
86
+ it('should preserve empty thinking text from display omitted', () => {
87
+ const data = {
88
+ content: [{
89
+ type: 'thinking',
90
+ thinking: '',
91
+ signature: 'sig-omitted'
92
+ }, {
93
+ type: 'text',
94
+ text: 'Hello'
95
+ }]
96
+ };
97
+
98
+ expect(MixAnthropic.extractThink(data)).to.equal('');
99
+ expect(MixAnthropic.extractSignature(data)).to.equal('sig-omitted');
100
+ });
101
+
102
+ it('should extract summarized thinking text', () => {
103
+ const data = {
104
+ content: [{
105
+ type: 'thinking',
106
+ thinking: 'Step by step...',
107
+ signature: 'sig-summarized'
108
+ }, {
109
+ type: 'text',
110
+ text: 'Answer'
111
+ }]
112
+ };
113
+
114
+ expect(MixAnthropic.extractThink(data)).to.equal('Step by step...');
115
+ expect(MixAnthropic.extractSignature(data)).to.equal('sig-summarized');
116
+ });
117
+
118
+ it('should return null when thinking block is missing', () => {
119
+ const data = {
120
+ content: [{ type: 'text', text: 'Hello' }]
121
+ };
122
+
123
+ expect(MixAnthropic.extractThink(data)).to.equal(null);
124
+ expect(MixAnthropic.extractSignature(data)).to.equal(null);
125
+ });
126
+
127
+ it('should persist Anthropic content blocks as assistantMessage', () => {
128
+ const content = [{
129
+ type: 'thinking',
130
+ thinking: '',
131
+ signature: 'sig-omitted'
132
+ }, {
133
+ type: 'text',
134
+ text: 'Hello'
135
+ }];
136
+ const provider = new MixAnthropic();
137
+ const result = provider.processResponse({ data: { content, usage: {} } });
138
+
139
+ expect(result.think).to.equal('');
140
+ expect(result.signature).to.equal('sig-omitted');
141
+ expect(result.assistantMessage).to.deep.equal({
142
+ role: 'assistant',
143
+ content
144
+ });
145
+ });
146
+
147
+ it('should keep tool_result after native Anthropic tool_use assistantMessage', () => {
148
+ // processResponse stores assistant content as Anthropic blocks (tool_use),
149
+ // not OpenAI-style tool_calls. convertMessages must still pair tool results.
150
+ const toolUseId = 'toolu_01TestToolUseId';
151
+ const converted = MixAnthropic.convertMessages([
152
+ { role: 'user', content: [{ type: 'text', text: 'What time is it?' }] },
153
+ {
154
+ role: 'assistant',
155
+ content: [{
156
+ type: 'tool_use',
157
+ id: toolUseId,
158
+ name: 'get_current_time',
159
+ input: {}
160
+ }]
161
+ },
162
+ {
163
+ role: 'tool',
164
+ tool_call_id: toolUseId,
165
+ name: 'get_current_time',
166
+ content: '2026-07-30T12:00:00Z'
167
+ }
168
+ ]);
169
+
170
+ expect(converted).to.have.length(3);
171
+ expect(converted[1]).to.deep.equal({
172
+ role: 'assistant',
173
+ content: [{
174
+ type: 'tool_use',
175
+ id: toolUseId,
176
+ name: 'get_current_time',
177
+ input: {}
178
+ }]
179
+ });
180
+ expect(converted[2]).to.deep.equal({
181
+ role: 'user',
182
+ content: [{
183
+ type: 'tool_result',
184
+ tool_use_id: toolUseId,
185
+ content: '2026-07-30T12:00:00Z'
186
+ }]
187
+ });
188
+ });
189
+ });
84
190
  });
@@ -456,6 +456,116 @@ describe('Conversation History Tests', () => {
456
456
  expect(capturedBody.messages[2].role).to.equal('user');
457
457
  });
458
458
 
459
+ it('should replay omitted thinking blocks with empty string (not null) on Anthropic', async () => {
460
+ // Opus 5 / Sonnet 5 / Fable 5 default to display "omitted":
461
+ // thinking blocks arrive as thinking: "" + signature.
462
+ // Replaying thinking: null causes Anthropic 400 invalid_request_error.
463
+ const model = ModelMix.new({
464
+ config: { debug: false, max_history: 10 }
465
+ });
466
+ model.opus5();
467
+
468
+ model.addText('Capital of France?');
469
+ nock('https://api.anthropic.com')
470
+ .post('/v1/messages')
471
+ .reply(200, {
472
+ content: [{
473
+ type: 'thinking',
474
+ thinking: '',
475
+ signature: 'sig-omitted-turn1'
476
+ }, {
477
+ type: 'text',
478
+ text: 'Paris.'
479
+ }]
480
+ });
481
+ await model.message();
482
+
483
+ expect(model.messages[1].content[0]).to.deep.equal({
484
+ type: 'thinking',
485
+ thinking: '',
486
+ signature: 'sig-omitted-turn1'
487
+ });
488
+ expect(model.messages[1].content[0].thinking).to.be.a('string');
489
+ expect(model.messages[1].content[0].thinking).to.not.equal(null);
490
+
491
+ let capturedBody;
492
+ model.addText('Capital of Germany?');
493
+ nock('https://api.anthropic.com')
494
+ .post('/v1/messages', (body) => {
495
+ capturedBody = body;
496
+ return true;
497
+ })
498
+ .reply(200, {
499
+ content: [{
500
+ type: 'thinking',
501
+ thinking: '',
502
+ signature: 'sig-omitted-turn2'
503
+ }, {
504
+ type: 'text',
505
+ text: 'Berlin.'
506
+ }]
507
+ });
508
+ await model.message();
509
+
510
+ const assistantMsg = capturedBody.messages.find(m => m.role === 'assistant');
511
+ expect(assistantMsg).to.exist;
512
+ expect(assistantMsg.content[0]).to.deep.equal({
513
+ type: 'thinking',
514
+ thinking: '',
515
+ signature: 'sig-omitted-turn1'
516
+ });
517
+ expect(assistantMsg.content[0].thinking).to.equal('');
518
+ expect(assistantMsg.content[1].text).to.equal('Paris.');
519
+ });
520
+
521
+ it('should replay summarized thinking blocks unchanged on Anthropic', async () => {
522
+ const model = ModelMix.new({
523
+ config: { debug: false, max_history: 10 }
524
+ });
525
+ model.opus5think();
526
+
527
+ model.addText('2+2?');
528
+ nock('https://api.anthropic.com')
529
+ .post('/v1/messages')
530
+ .reply(200, {
531
+ content: [{
532
+ type: 'thinking',
533
+ thinking: 'Simple arithmetic.',
534
+ signature: 'sig-summarized-turn1'
535
+ }, {
536
+ type: 'text',
537
+ text: '4'
538
+ }]
539
+ });
540
+ await model.message();
541
+
542
+ let capturedBody;
543
+ model.addText('3+3?');
544
+ nock('https://api.anthropic.com')
545
+ .post('/v1/messages', (body) => {
546
+ capturedBody = body;
547
+ return true;
548
+ })
549
+ .reply(200, {
550
+ content: [{
551
+ type: 'thinking',
552
+ thinking: 'Also simple.',
553
+ signature: 'sig-summarized-turn2'
554
+ }, {
555
+ type: 'text',
556
+ text: '6'
557
+ }]
558
+ });
559
+ await model.message();
560
+
561
+ const assistantMsg = capturedBody.messages.find(m => m.role === 'assistant');
562
+ expect(assistantMsg.content[0]).to.deep.equal({
563
+ type: 'thinking',
564
+ thinking: 'Simple arithmetic.',
565
+ signature: 'sig-summarized-turn1'
566
+ });
567
+ });
568
+
459
569
  it('should maintain history when using Google provider', async () => {
460
570
  const model = ModelMix.new({
461
571
  config: { debug: false, max_history: 10 }
package/test/live.mcp.js CHANGED
@@ -139,6 +139,48 @@ describe('Live MCP Integration Tests', function () {
139
139
  }
140
140
  });
141
141
 
142
+ it('should use custom MCP tools with Claude Opus 5', async function () {
143
+ // Opus 5 rejects temperature (deprecated); omit it from options.
144
+ const model = ModelMix.new({ config: setup.config }).opus5();
145
+
146
+ model.addTool({
147
+ name: "get_current_time",
148
+ description: "Get the current date and time",
149
+ inputSchema: {
150
+ type: "object",
151
+ properties: {
152
+ timezone: {
153
+ type: "string",
154
+ description: "Timezone (optional, defaults to UTC)"
155
+ }
156
+ }
157
+ }
158
+ }, async ({ timezone = 'UTC' }) => {
159
+ const now = new Date();
160
+ if (timezone === 'UTC') {
161
+ return `Current time (UTC): ${now.toISOString()}`;
162
+ }
163
+ return `Current time (${timezone}): ${now.toLocaleString('en-US', { timeZone: timezone })}`;
164
+ });
165
+
166
+ model.setSystem('You are a helpful assistant that can tell time. Use the get_current_time tool when asked about time.');
167
+ model.addText('What time is it right now?');
168
+
169
+ try {
170
+ const response = await withRetry(() => model.message(), { retries: 2, baseDelayMs: 1500 });
171
+ console.log(`Claude Opus 5 with MCP tools: ${response}`);
172
+
173
+ expect(response).to.be.a('string');
174
+ expect(response.toLowerCase()).to.match(/(time|clock|hour|minute|second|am|pm|utc|\d{4})/);
175
+ } catch (error) {
176
+ console.error('Full error:', error);
177
+ if (error.response && error.response.data) {
178
+ console.error('Response data:', JSON.stringify(error.response.data, null, 2));
179
+ }
180
+ throw error;
181
+ }
182
+ });
183
+
142
184
  it('should use custom MCP tools with Gemini 3 Flash', async function () {
143
185
  const model = ModelMix.new(setup).gemini3flash();
144
186
 
@@ -517,7 +559,7 @@ describe('Live MCP Integration Tests', function () {
517
559
 
518
560
  it('should work with same MCP tools across different Anthropic models', async function () {
519
561
  const models = [
520
- { name: 'Sonnet 4', model: ModelMix.new(setup).sonnet46() },
562
+ { name: 'Opus 5', model: ModelMix.new({ config: setup.config }).opus5() },
521
563
  { name: 'Sonnet 4.6', model: ModelMix.new(setup).sonnet46() },
522
564
  { name: 'Haiku 4.5', model: ModelMix.new(setup).haiku45() }
523
565
  ];