modelmix 4.4.4 → 4.4.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.
@@ -0,0 +1,572 @@
1
+ const { expect } = require('chai');
2
+ const sinon = require('sinon');
3
+ const nock = require('nock');
4
+ const { ModelMix } = require('../index.js');
5
+
6
+ describe('Conversation History Tests', () => {
7
+
8
+ if (global.setupTestHooks) {
9
+ global.setupTestHooks();
10
+ }
11
+
12
+ afterEach(() => {
13
+ nock.cleanAll();
14
+ sinon.restore();
15
+ });
16
+
17
+ describe('Assistant Response Persistence', () => {
18
+ let model;
19
+
20
+ beforeEach(() => {
21
+ model = ModelMix.new({
22
+ config: { debug: false, max_history: 10 }
23
+ });
24
+ });
25
+
26
+ it('should add assistant response to message history after message()', async () => {
27
+ model.gpt5mini().addText('Hello');
28
+
29
+ nock('https://api.openai.com')
30
+ .post('/v1/chat/completions')
31
+ .reply(200, {
32
+ choices: [{
33
+ message: {
34
+ role: 'assistant',
35
+ content: 'Hi there!'
36
+ }
37
+ }]
38
+ });
39
+
40
+ await model.message();
41
+
42
+ // After the call, messages should contain both user and assistant
43
+ expect(model.messages).to.have.length(2);
44
+ expect(model.messages[0].role).to.equal('user');
45
+ expect(model.messages[1].role).to.equal('assistant');
46
+ expect(model.messages[1].content[0].text).to.equal('Hi there!');
47
+ });
48
+
49
+ it('should add assistant response to message history after raw()', async () => {
50
+ model.sonnet46().addText('Hello');
51
+
52
+ nock('https://api.anthropic.com')
53
+ .post('/v1/messages')
54
+ .reply(200, {
55
+ content: [{
56
+ type: 'text',
57
+ text: 'Hi from Claude!'
58
+ }]
59
+ });
60
+
61
+ await model.raw();
62
+
63
+ expect(model.messages).to.have.length(2);
64
+ expect(model.messages[0].role).to.equal('user');
65
+ expect(model.messages[1].role).to.equal('assistant');
66
+ expect(model.messages[1].content[0].text).to.equal('Hi from Claude!');
67
+ });
68
+ });
69
+
70
+ describe('Multi-turn Conversations', () => {
71
+
72
+ it('should include previous assistant response in second API call (OpenAI)', async () => {
73
+ const model = ModelMix.new({
74
+ config: { debug: false, max_history: 10 }
75
+ });
76
+ model.gpt5mini();
77
+
78
+ // First turn
79
+ model.addText('Capital of France?');
80
+
81
+ nock('https://api.openai.com')
82
+ .post('/v1/chat/completions')
83
+ .reply(200, {
84
+ choices: [{
85
+ message: {
86
+ role: 'assistant',
87
+ content: 'The capital of France is Paris.'
88
+ }
89
+ }]
90
+ });
91
+
92
+ await model.message();
93
+
94
+ // Second turn - capture the request body to verify history
95
+ let capturedBody;
96
+ nock('https://api.openai.com')
97
+ .post('/v1/chat/completions', (body) => {
98
+ capturedBody = body;
99
+ return true;
100
+ })
101
+ .reply(200, {
102
+ choices: [{
103
+ message: {
104
+ role: 'assistant',
105
+ content: 'The capital of Germany is Berlin.'
106
+ }
107
+ }]
108
+ });
109
+
110
+ model.addText('Capital of Germany?');
111
+ await model.message();
112
+
113
+ // Verify the second request includes system + user + assistant + user
114
+ expect(capturedBody.messages).to.have.length(4); // system + 3 conversation messages
115
+ expect(capturedBody.messages[0].role).to.equal('system');
116
+ expect(capturedBody.messages[1].role).to.equal('user');
117
+ expect(capturedBody.messages[2].role).to.equal('assistant');
118
+ // OpenAI content is an array of {type, text} objects
119
+ const assistantContent = capturedBody.messages[2].content;
120
+ const assistantText = Array.isArray(assistantContent)
121
+ ? assistantContent[0].text
122
+ : assistantContent;
123
+ expect(assistantText).to.include('Paris');
124
+ expect(capturedBody.messages[3].role).to.equal('user');
125
+ });
126
+
127
+ it('should include previous assistant response in second API call (Anthropic)', async () => {
128
+ const model = ModelMix.new({
129
+ config: { debug: false, max_history: 10 }
130
+ });
131
+ model.sonnet46();
132
+
133
+ // First turn
134
+ model.addText('Capital of France?');
135
+
136
+ nock('https://api.anthropic.com')
137
+ .post('/v1/messages')
138
+ .reply(200, {
139
+ content: [{
140
+ type: 'text',
141
+ text: 'The capital of France is Paris.'
142
+ }]
143
+ });
144
+
145
+ await model.message();
146
+
147
+ // Second turn - capture the request body
148
+ let capturedBody;
149
+ nock('https://api.anthropic.com')
150
+ .post('/v1/messages', (body) => {
151
+ capturedBody = body;
152
+ return true;
153
+ })
154
+ .reply(200, {
155
+ content: [{
156
+ type: 'text',
157
+ text: 'The capital of Germany is Berlin.'
158
+ }]
159
+ });
160
+
161
+ model.addText('Capital of Germany?');
162
+ await model.message();
163
+
164
+ // Anthropic: system is separate, messages should be user/assistant/user
165
+ expect(capturedBody.messages).to.have.length(3);
166
+ expect(capturedBody.messages[0].role).to.equal('user');
167
+ expect(capturedBody.messages[1].role).to.equal('assistant');
168
+ expect(capturedBody.messages[1].content[0].text).to.include('Paris');
169
+ expect(capturedBody.messages[2].role).to.equal('user');
170
+ });
171
+
172
+ it('should not merge consecutive user messages when assistant response is between them', async () => {
173
+ const model = ModelMix.new({
174
+ config: { debug: false, max_history: 10 }
175
+ });
176
+ model.gpt5mini();
177
+
178
+ model.addText('First question');
179
+
180
+ nock('https://api.openai.com')
181
+ .post('/v1/chat/completions')
182
+ .reply(200, {
183
+ choices: [{
184
+ message: {
185
+ role: 'assistant',
186
+ content: 'First answer'
187
+ }
188
+ }]
189
+ });
190
+
191
+ await model.message();
192
+
193
+ // Capture second request
194
+ let capturedBody;
195
+ nock('https://api.openai.com')
196
+ .post('/v1/chat/completions', (body) => {
197
+ capturedBody = body;
198
+ return true;
199
+ })
200
+ .reply(200, {
201
+ choices: [{
202
+ message: {
203
+ role: 'assistant',
204
+ content: 'Second answer'
205
+ }
206
+ }]
207
+ });
208
+
209
+ model.addText('Second question');
210
+ await model.message();
211
+
212
+ // The two user messages must NOT be merged into one
213
+ const userMessages = capturedBody.messages.filter(m => m.role === 'user');
214
+ expect(userMessages).to.have.length(2);
215
+ expect(userMessages[0].content[0].text).to.equal('First question');
216
+ expect(userMessages[1].content[0].text).to.equal('Second question');
217
+ });
218
+
219
+ it('should maintain correct alternating roles across 3 turns', async () => {
220
+ const model = ModelMix.new({
221
+ config: { debug: false, max_history: 20 }
222
+ });
223
+ model.gpt5mini();
224
+
225
+ const turns = [
226
+ { user: 'Question 1', assistant: 'Answer 1' },
227
+ { user: 'Question 2', assistant: 'Answer 2' },
228
+ { user: 'Question 3', assistant: 'Answer 3' },
229
+ ];
230
+
231
+ let capturedBody;
232
+
233
+ for (const turn of turns) {
234
+ model.addText(turn.user);
235
+
236
+ nock('https://api.openai.com')
237
+ .post('/v1/chat/completions', (body) => {
238
+ capturedBody = body;
239
+ return true;
240
+ })
241
+ .reply(200, {
242
+ choices: [{
243
+ message: {
244
+ role: 'assistant',
245
+ content: turn.assistant
246
+ }
247
+ }]
248
+ });
249
+
250
+ await model.message();
251
+ }
252
+
253
+ // After 3 turns, the last request should have system + 5 messages (u/a/u/a/u)
254
+ const msgs = capturedBody.messages.filter(m => m.role !== 'system');
255
+ expect(msgs).to.have.length(5);
256
+ expect(msgs.map(m => m.role)).to.deep.equal([
257
+ 'user', 'assistant', 'user', 'assistant', 'user'
258
+ ]);
259
+ });
260
+ });
261
+
262
+ describe('max_history Limits', () => {
263
+
264
+ it('should be stateless with max_history=0 (default)', async () => {
265
+ const model = ModelMix.new({
266
+ config: { debug: false } // max_history defaults to 0
267
+ });
268
+ model.gpt5mini();
269
+
270
+ model.addText('Question 1');
271
+ nock('https://api.openai.com')
272
+ .post('/v1/chat/completions')
273
+ .reply(200, {
274
+ choices: [{
275
+ message: { role: 'assistant', content: 'Answer 1' }
276
+ }]
277
+ });
278
+ await model.message();
279
+
280
+ // After call, messages should be cleared (stateless)
281
+ expect(model.messages).to.have.length(0);
282
+ });
283
+
284
+ it('should not send history on second call with max_history=0', async () => {
285
+ const model = ModelMix.new({
286
+ config: { debug: false, max_history: 0 }
287
+ });
288
+ model.gpt5mini();
289
+
290
+ // First turn
291
+ model.addText('Question 1');
292
+ nock('https://api.openai.com')
293
+ .post('/v1/chat/completions')
294
+ .reply(200, {
295
+ choices: [{
296
+ message: { role: 'assistant', content: 'Answer 1' }
297
+ }]
298
+ });
299
+ await model.message();
300
+
301
+ // Second turn - capture request
302
+ let capturedBody;
303
+ model.addText('Question 2');
304
+ nock('https://api.openai.com')
305
+ .post('/v1/chat/completions', (body) => {
306
+ capturedBody = body;
307
+ return true;
308
+ })
309
+ .reply(200, {
310
+ choices: [{
311
+ message: { role: 'assistant', content: 'Answer 2' }
312
+ }]
313
+ });
314
+ await model.message();
315
+
316
+ // Only system + current user message, no history from turn 1
317
+ const msgs = capturedBody.messages.filter(m => m.role !== 'system');
318
+ expect(msgs).to.have.length(1);
319
+ expect(msgs[0].role).to.equal('user');
320
+ expect(msgs[0].content[0].text).to.equal('Question 2');
321
+ });
322
+
323
+ it('should trim old messages when max_history is reached', async () => {
324
+ const model = ModelMix.new({
325
+ config: { debug: false, max_history: 2 }
326
+ });
327
+ model.gpt5mini();
328
+
329
+ // Turn 1
330
+ model.addText('Question 1');
331
+ nock('https://api.openai.com')
332
+ .post('/v1/chat/completions')
333
+ .reply(200, {
334
+ choices: [{
335
+ message: { role: 'assistant', content: 'Answer 1' }
336
+ }]
337
+ });
338
+ await model.message();
339
+
340
+ // Turn 2 - capture request
341
+ let capturedBody;
342
+ model.addText('Question 2');
343
+ nock('https://api.openai.com')
344
+ .post('/v1/chat/completions', (body) => {
345
+ capturedBody = body;
346
+ return true;
347
+ })
348
+ .reply(200, {
349
+ choices: [{
350
+ message: { role: 'assistant', content: 'Answer 2' }
351
+ }]
352
+ });
353
+ await model.message();
354
+
355
+ // With max_history=2, only the last 2 messages should be sent (assistant + user)
356
+ const msgs = capturedBody.messages.filter(m => m.role !== 'system');
357
+ expect(msgs.length).to.be.at.most(2);
358
+ // The last message should be the current user question
359
+ expect(msgs[msgs.length - 1].role).to.equal('user');
360
+ expect(msgs[msgs.length - 1].content[0].text).to.equal('Question 2');
361
+ });
362
+
363
+ it('should keep full history when max_history is large enough', async () => {
364
+ const model = ModelMix.new({
365
+ config: { debug: false, max_history: 100 }
366
+ });
367
+ model.gpt5mini();
368
+
369
+ // Turn 1
370
+ model.addText('Q1');
371
+ nock('https://api.openai.com')
372
+ .post('/v1/chat/completions')
373
+ .reply(200, {
374
+ choices: [{
375
+ message: { role: 'assistant', content: 'A1' }
376
+ }]
377
+ });
378
+ await model.message();
379
+
380
+ // Turn 2
381
+ let capturedBody;
382
+ model.addText('Q2');
383
+ nock('https://api.openai.com')
384
+ .post('/v1/chat/completions', (body) => {
385
+ capturedBody = body;
386
+ return true;
387
+ })
388
+ .reply(200, {
389
+ choices: [{
390
+ message: { role: 'assistant', content: 'A2' }
391
+ }]
392
+ });
393
+ await model.message();
394
+
395
+ // All 3 messages should be present (user, assistant, user)
396
+ const msgs = capturedBody.messages.filter(m => m.role !== 'system');
397
+ expect(msgs).to.have.length(3);
398
+ });
399
+
400
+ it('should handle max_history=-1 (unlimited)', async () => {
401
+ const model = ModelMix.new({
402
+ config: { debug: false, max_history: -1 }
403
+ });
404
+ model.gpt5mini();
405
+
406
+ for (let i = 1; i <= 5; i++) {
407
+ model.addText(`Question ${i}`);
408
+ nock('https://api.openai.com')
409
+ .post('/v1/chat/completions')
410
+ .reply(200, {
411
+ choices: [{
412
+ message: { role: 'assistant', content: `Answer ${i}` }
413
+ }]
414
+ });
415
+ await model.message();
416
+ }
417
+
418
+ // After 5 turns, all 10 messages should be in history (5 user + 5 assistant)
419
+ expect(model.messages).to.have.length(10);
420
+ });
421
+ });
422
+
423
+ describe('Cross-provider History', () => {
424
+
425
+ it('should maintain history when using Anthropic provider', async () => {
426
+ const model = ModelMix.new({
427
+ config: { debug: false, max_history: 10 }
428
+ });
429
+ model.haiku45();
430
+
431
+ model.addText('Hello');
432
+ nock('https://api.anthropic.com')
433
+ .post('/v1/messages')
434
+ .reply(200, {
435
+ content: [{ type: 'text', text: 'Hi there!' }]
436
+ });
437
+ await model.message();
438
+
439
+ let capturedBody;
440
+ model.addText('How are you?');
441
+ nock('https://api.anthropic.com')
442
+ .post('/v1/messages', (body) => {
443
+ capturedBody = body;
444
+ return true;
445
+ })
446
+ .reply(200, {
447
+ content: [{ type: 'text', text: 'I am well!' }]
448
+ });
449
+ await model.message();
450
+
451
+ // Anthropic sends system separately; messages should be u/a/u
452
+ expect(capturedBody.messages).to.have.length(3);
453
+ expect(capturedBody.messages[0].role).to.equal('user');
454
+ expect(capturedBody.messages[1].role).to.equal('assistant');
455
+ expect(capturedBody.messages[1].content[0].text).to.equal('Hi there!');
456
+ expect(capturedBody.messages[2].role).to.equal('user');
457
+ });
458
+
459
+ it('should maintain history when using Google provider', async () => {
460
+ const model = ModelMix.new({
461
+ config: { debug: false, max_history: 10 }
462
+ });
463
+ model.gemini3flash();
464
+
465
+ model.addText('Hello');
466
+ nock('https://generativelanguage.googleapis.com')
467
+ .post(/.*generateContent/)
468
+ .reply(200, {
469
+ candidates: [{
470
+ content: {
471
+ parts: [{ text: 'Hi from Gemini!' }]
472
+ }
473
+ }]
474
+ });
475
+ await model.message();
476
+
477
+ let capturedBody;
478
+ model.addText('How are you?');
479
+ nock('https://generativelanguage.googleapis.com')
480
+ .post(/.*generateContent/, (body) => {
481
+ capturedBody = body;
482
+ return true;
483
+ })
484
+ .reply(200, {
485
+ candidates: [{
486
+ content: {
487
+ parts: [{ text: 'Great, thanks!' }]
488
+ }
489
+ }]
490
+ });
491
+ await model.message();
492
+
493
+ // Google sends messages in contents array with user/model roles
494
+ const userMsgs = capturedBody.contents.filter(m => m.role === 'user');
495
+ const modelMsgs = capturedBody.contents.filter(m => m.role === 'model');
496
+ expect(userMsgs).to.have.length(2);
497
+ expect(modelMsgs).to.have.length(1);
498
+ });
499
+ });
500
+
501
+ describe('Edge Cases', () => {
502
+
503
+ it('should handle single turn without breaking', async () => {
504
+ const model = ModelMix.new({
505
+ config: { debug: false, max_history: 10 }
506
+ });
507
+ model.gpt5mini().addText('Just one question');
508
+
509
+ nock('https://api.openai.com')
510
+ .post('/v1/chat/completions')
511
+ .reply(200, {
512
+ choices: [{
513
+ message: { role: 'assistant', content: 'Just one answer' }
514
+ }]
515
+ });
516
+
517
+ const response = await model.message();
518
+ expect(response).to.equal('Just one answer');
519
+ expect(model.messages).to.have.length(2);
520
+ });
521
+
522
+ it('should handle empty assistant response gracefully', async () => {
523
+ const model = ModelMix.new({
524
+ config: { debug: false, max_history: 10 }
525
+ });
526
+ model.gpt5mini().addText('Hello');
527
+
528
+ nock('https://api.openai.com')
529
+ .post('/v1/chat/completions')
530
+ .reply(200, {
531
+ choices: [{
532
+ message: { role: 'assistant', content: '' }
533
+ }]
534
+ });
535
+
536
+ const response = await model.message();
537
+ // Empty string is falsy, so assistant message should NOT be added
538
+ expect(response).to.equal('');
539
+ });
540
+
541
+ it('should handle multiple addText before first message()', async () => {
542
+ const model = ModelMix.new({
543
+ config: { debug: false, max_history: 10 }
544
+ });
545
+ model.gpt5mini();
546
+
547
+ model.addText('Part 1');
548
+ model.addText('Part 2');
549
+
550
+ let capturedBody;
551
+ nock('https://api.openai.com')
552
+ .post('/v1/chat/completions', (body) => {
553
+ capturedBody = body;
554
+ return true;
555
+ })
556
+ .reply(200, {
557
+ choices: [{
558
+ message: { role: 'assistant', content: 'Response' }
559
+ }]
560
+ });
561
+
562
+ await model.message();
563
+
564
+ // Two consecutive user messages should be grouped into one by groupByRoles
565
+ const userMsgs = capturedBody.messages.filter(m => m.role === 'user');
566
+ expect(userMsgs).to.have.length(1);
567
+ expect(userMsgs[0].content).to.have.length(2);
568
+ expect(userMsgs[0].content[0].text).to.equal('Part 1');
569
+ expect(userMsgs[0].content[1].text).to.equal('Part 2');
570
+ });
571
+ });
572
+ });
@@ -25,7 +25,7 @@ describe('Image Processing and Multimodal Support Tests', () => {
25
25
  it('should handle base64 image data correctly', async () => {
26
26
  const base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC';
27
27
 
28
- model.gpt41()
28
+ model.gpt52()
29
29
  .addText('What do you see in this image?')
30
30
  .addImageFromUrl(base64Image);
31
31
 
@@ -50,10 +50,10 @@ describe('Image Processing and Multimodal Support Tests', () => {
50
50
  expect(response).to.include('I can see a small test image');
51
51
  });
52
52
 
53
- it('should support multimodal with sonnet4()', async () => {
53
+ it('should support multimodal with sonnet46()', async () => {
54
54
  const base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC';
55
55
 
56
- model.sonnet4()
56
+ model.sonnet46()
57
57
  .addText('Describe this image')
58
58
  .addImageFromUrl(base64Image);
59
59