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 +23 -0
- package/index.js +41 -16
- package/package.json +1 -1
- package/test/anthropic.test.js +106 -0
- package/test/history.test.js +110 -0
- package/test/live.mcp.js +43 -1
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
2388
|
-
think: MixAnthropic.extractThink(
|
|
2389
|
-
toolCalls: MixAnthropic.extractToolCalls(
|
|
2390
|
-
tokens: MixAnthropic.extractTokens(
|
|
2391
|
-
response:
|
|
2392
|
-
signature: MixAnthropic.extractSignature(
|
|
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
package/test/anthropic.test.js
CHANGED
|
@@ -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
|
});
|
package/test/history.test.js
CHANGED
|
@@ -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: '
|
|
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
|
];
|