modelmix 4.4.4 → 4.4.6

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/json.js CHANGED
@@ -11,17 +11,19 @@ const model = await ModelMix.new({ options: { max_tokens: 10000 }, config: { deb
11
11
  // .gemini25flash()
12
12
  .addText("Name and capital of 3 South American countries.")
13
13
 
14
- const jsonResult = await model.json({
15
- countries: [{
16
- name: "Argentina",
17
- capital: "BUENOS AIRES"
18
- }]
14
+ const jsonResult = await model.json([{
15
+ name: "Argentina",
16
+ capital: "BUENOS AIRES"
19
17
  }, {
20
- countries: [{
21
- name: "name of the country",
22
- capital: "capital of the country in uppercase"
23
- }]
24
- }, { addNote: true });
18
+ name: "Brazil",
19
+ capital: "BRASILIA"
20
+ }, {
21
+ name: "Colombia",
22
+ capital: "BOGOTA"
23
+ }], [{
24
+ name: { description: "name of the country", enum: ["Perú", "Colombia", "Argentina"] },
25
+ capital: "capital of the country in uppercase"
26
+ }], { addNote: true });
25
27
 
26
28
  console.log(jsonResult);
27
29
  console.log(model.lastRaw.tokens);
package/index.js CHANGED
@@ -112,7 +112,7 @@ class ModelMix {
112
112
 
113
113
  this.config = {
114
114
  system: 'You are an assistant.',
115
- max_history: 1, // Default max history
115
+ max_history: 0, // 0=no history (stateless), N=keep last N messages, -1=unlimited
116
116
  debug: 0, // 0=silent, 1=minimal, 2=readable summary, 3=full (no truncate), 4=verbose (raw details)
117
117
  bottleneck: defaultBottleneckConfig,
118
118
  roundRobin: false, // false=fallback mode, true=round robin rotation
@@ -640,6 +640,15 @@ class ModelMix {
640
640
 
641
641
  async json(schemaExample = null, schemaDescription = {}, { type = 'json_object', addExample = false, addSchema = true, addNote = false } = {}) {
642
642
 
643
+ let isArrayWrap = false;
644
+ if (Array.isArray(schemaExample)) {
645
+ isArrayWrap = true;
646
+ schemaExample = { out: schemaExample };
647
+ if (Array.isArray(schemaDescription)) {
648
+ schemaDescription = { out: schemaDescription };
649
+ }
650
+ }
651
+
643
652
  let options = {
644
653
  response_format: { type },
645
654
  stream: false,
@@ -666,7 +675,8 @@ class ModelMix {
666
675
  }
667
676
  }
668
677
  const { message } = await this.execute({ options, config });
669
- return JSON.parse(this._extractBlock(message));
678
+ const parsed = JSON.parse(this._extractBlock(message));
679
+ return isArrayWrap ? parsed.out : parsed;
670
680
  }
671
681
 
672
682
  _extractBlock(response) {
@@ -760,7 +770,8 @@ class ModelMix {
760
770
  await this.processImages();
761
771
  this.applyTemplate();
762
772
 
763
- // Smart message slicing to preserve tool call sequences
773
+ // Smart message slicing based on max_history:
774
+ // 0 = no history (stateless), N = keep last N messages, -1 = unlimited
764
775
  if (this.config.max_history > 0) {
765
776
  let sliceStart = Math.max(0, this.messages.length - this.config.max_history);
766
777
 
@@ -780,6 +791,8 @@ class ModelMix {
780
791
 
781
792
  this.messages = this.messages.slice(sliceStart);
782
793
  }
794
+ // max_history = -1: unlimited, no slicing
795
+ // max_history = 0: no history, messages only contain what was added since last call
783
796
 
784
797
  this.messages = this.groupByRoles(this.messages);
785
798
  this.options.messages = this.messages;
@@ -941,6 +954,29 @@ class ModelMix {
941
954
  if (currentConfig.debug >= 1) console.log('');
942
955
 
943
956
  this.lastRaw = result;
957
+
958
+ // Manage conversation history based on max_history setting
959
+ if (this.config.max_history === 0) {
960
+ // Stateless: clear messages so next call starts fresh
961
+ this.messages = [];
962
+ } else if (result.message) {
963
+ // Persist assistant response for multi-turn conversations
964
+ if (result.signature) {
965
+ this.messages.push({
966
+ role: "assistant", content: [{
967
+ type: "thinking",
968
+ thinking: result.think,
969
+ signature: result.signature
970
+ }, {
971
+ type: "text",
972
+ text: result.message
973
+ }]
974
+ });
975
+ } else {
976
+ this.addText(result.message, { role: "assistant" });
977
+ }
978
+ }
979
+
944
980
  return result;
945
981
 
946
982
  } catch (error) {
@@ -1043,7 +1079,7 @@ class ModelMix {
1043
1079
  return;
1044
1080
  }
1045
1081
 
1046
- if (this.config.max_history < 3) {
1082
+ if (this.config.max_history >= 0 && this.config.max_history < 3) {
1047
1083
  log.warn(`MCP ${key} requires at least 3 max_history. Setting to 3.`);
1048
1084
  this.config.max_history = 3;
1049
1085
  }
@@ -1079,7 +1115,7 @@ class ModelMix {
1079
1115
 
1080
1116
  addTool(toolDefinition, callback) {
1081
1117
 
1082
- if (this.config.max_history < 3) {
1118
+ if (this.config.max_history >= 0 && this.config.max_history < 3) {
1083
1119
  log.warn(`MCP ${toolDefinition.name} requires at least 3 max_history. Setting to 3.`);
1084
1120
  this.config.max_history = 3;
1085
1121
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelmix",
3
- "version": "4.4.4",
3
+ "version": "4.4.6",
4
4
  "description": "🧬 Reliable interface with automatic fallback for AI LLMs.",
5
5
  "main": "index.js",
6
6
  "repository": {
package/schema.js CHANGED
@@ -1,3 +1,29 @@
1
+ const META_KEYS = new Set(['description', 'required', 'enum', 'default']);
2
+
3
+ function isDescriptor(value) {
4
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
5
+ const keys = Object.keys(value);
6
+ return keys.length > 0 && keys.every(k => META_KEYS.has(k));
7
+ }
8
+
9
+ function makeNullable(fieldSchema) {
10
+ if (!fieldSchema.type) return fieldSchema;
11
+ if (Array.isArray(fieldSchema.type)) {
12
+ if (!fieldSchema.type.includes('null')) fieldSchema.type.push('null');
13
+ } else {
14
+ fieldSchema.type = [fieldSchema.type, 'null'];
15
+ }
16
+ return fieldSchema;
17
+ }
18
+
19
+ function getNestedDescriptions(desc) {
20
+ if (!desc) return {};
21
+ if (typeof desc === 'string') return {};
22
+ if (Array.isArray(desc)) return desc[0] || {};
23
+ if (isDescriptor(desc)) return {};
24
+ return desc;
25
+ }
26
+
1
27
  function generateJsonSchema(example, descriptions = {}) {
2
28
  function detectType(key, value) {
3
29
  if (value === null) return { type: 'null' };
@@ -32,7 +58,7 @@ function generateJsonSchema(example, descriptions = {}) {
32
58
  if (typeof value[0] === 'object' && !Array.isArray(value[0])) {
33
59
  return {
34
60
  type: 'array',
35
- items: generateJsonSchema(value[0], descriptions[key] || {})
61
+ items: generateJsonSchema(value[0], getNestedDescriptions(descriptions[key]))
36
62
  };
37
63
  } else {
38
64
  return {
@@ -42,7 +68,7 @@ function generateJsonSchema(example, descriptions = {}) {
42
68
  }
43
69
  }
44
70
  if (typeof value === 'object') {
45
- return generateJsonSchema(value, descriptions[key] || {});
71
+ return generateJsonSchema(value, getNestedDescriptions(descriptions[key]));
46
72
  }
47
73
  return {};
48
74
  }
@@ -65,13 +91,28 @@ function generateJsonSchema(example, descriptions = {}) {
65
91
 
66
92
  for (const key in example) {
67
93
  const fieldSchema = detectType(key, example[key]);
94
+ const desc = descriptions[key];
95
+ let isRequired = true;
68
96
 
69
- if (descriptions[key] && typeof fieldSchema === 'object') {
70
- fieldSchema.description = descriptions[key];
97
+ if (desc) {
98
+ if (typeof desc === 'string') {
99
+ fieldSchema.description = desc;
100
+ } else if (typeof desc === 'object' && !Array.isArray(desc) && isDescriptor(desc)) {
101
+ if (desc.description) fieldSchema.description = desc.description;
102
+ if (desc.enum) fieldSchema.enum = desc.enum;
103
+ if (desc.default !== undefined) fieldSchema.default = desc.default;
104
+ if (desc.required === false) {
105
+ isRequired = false;
106
+ makeNullable(fieldSchema);
107
+ }
108
+ if (desc.enum && desc.enum.includes(null)) {
109
+ makeNullable(fieldSchema);
110
+ }
111
+ }
71
112
  }
72
113
 
73
114
  schema.properties[key] = fieldSchema;
74
- schema.required.push(key);
115
+ if (isRequired) schema.required.push(key);
75
116
  }
76
117
 
77
118
  return schema;
@@ -115,6 +115,7 @@ describe('Rate Limiting with Bottleneck Tests', () => {
115
115
  model = ModelMix.new({
116
116
  config: {
117
117
  debug: false,
118
+ max_history: -1, // concurrent calls need history to preserve queued messages
118
119
  bottleneck: {
119
120
  maxConcurrent: 2,
120
121
  minTime: 50
@@ -335,6 +336,7 @@ describe('Rate Limiting with Bottleneck Tests', () => {
335
336
  const model = ModelMix.new({
336
337
  config: {
337
338
  debug: false,
339
+ max_history: -1,
338
340
  bottleneck: {
339
341
  maxConcurrent: 5,
340
342
  minTime: 100,
@@ -385,6 +387,7 @@ describe('Rate Limiting with Bottleneck Tests', () => {
385
387
  const model = ModelMix.new({
386
388
  config: {
387
389
  debug: false,
390
+ max_history: -1,
388
391
  bottleneck: {
389
392
  maxConcurrent: 1,
390
393
  minTime: 200
@@ -432,6 +435,7 @@ describe('Rate Limiting with Bottleneck Tests', () => {
432
435
  const model = ModelMix.new({
433
436
  config: {
434
437
  debug: false,
438
+ max_history: -1,
435
439
  bottleneck: {
436
440
  maxConcurrent: 2,
437
441
  minTime: 100,
@@ -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.sonnet4().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.sonnet4();
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.haiku35();
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.gemini25flash();
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
+ });
package/test/json.test.js CHANGED
@@ -181,6 +181,163 @@ describe('JSON Schema and Structured Output Tests', () => {
181
181
  });
182
182
  });
183
183
 
184
+ describe('Enhanced Descriptor Descriptions', () => {
185
+ it('should support required: false (not in required + nullable)', () => {
186
+ const example = { name: 'Alice', nickname: 'Ali' };
187
+ const descriptions = {
188
+ name: 'Full name',
189
+ nickname: { description: 'Optional nickname', required: false }
190
+ };
191
+ const schema = generateJsonSchema(example, descriptions);
192
+
193
+ expect(schema.required).to.deep.equal(['name']);
194
+ expect(schema.properties.nickname).to.deep.equal({
195
+ type: ['string', 'null'],
196
+ description: 'Optional nickname'
197
+ });
198
+ expect(schema.properties.name).to.deep.equal({
199
+ type: 'string',
200
+ description: 'Full name'
201
+ });
202
+ });
203
+
204
+ it('should support enum', () => {
205
+ const example = { status: 'active' };
206
+ const descriptions = {
207
+ status: { description: 'Account status', enum: ['active', 'inactive', 'banned'] }
208
+ };
209
+ const schema = generateJsonSchema(example, descriptions);
210
+
211
+ expect(schema.properties.status).to.deep.equal({
212
+ type: 'string',
213
+ description: 'Account status',
214
+ enum: ['active', 'inactive', 'banned']
215
+ });
216
+ expect(schema.required).to.deep.equal(['status']);
217
+ });
218
+
219
+ it('should make type nullable when enum includes null', () => {
220
+ const example = { sex: 'm' };
221
+ const descriptions = {
222
+ sex: { description: 'Gender', enum: ['m', 'f', null] }
223
+ };
224
+ const schema = generateJsonSchema(example, descriptions);
225
+
226
+ expect(schema.properties.sex).to.deep.equal({
227
+ type: ['string', 'null'],
228
+ description: 'Gender',
229
+ enum: ['m', 'f', null]
230
+ });
231
+ });
232
+
233
+ it('should support default value', () => {
234
+ const example = { theme: 'light' };
235
+ const descriptions = {
236
+ theme: { description: 'UI theme', default: 'light', enum: ['light', 'dark'] }
237
+ };
238
+ const schema = generateJsonSchema(example, descriptions);
239
+
240
+ expect(schema.properties.theme).to.deep.equal({
241
+ type: 'string',
242
+ description: 'UI theme',
243
+ default: 'light',
244
+ enum: ['light', 'dark']
245
+ });
246
+ });
247
+
248
+ it('should mix string and descriptor descriptions', () => {
249
+ const example = { name: 'martin', age: 22, sex: 'm' };
250
+ const descriptions = {
251
+ name: { description: 'Name of the actor', required: false },
252
+ age: 'Age of the actor',
253
+ sex: { description: 'Gender', enum: ['m', 'f', null], default: 'm' }
254
+ };
255
+ const schema = generateJsonSchema(example, descriptions);
256
+
257
+ expect(schema.required).to.deep.equal(['age', 'sex']);
258
+ expect(schema.properties.name).to.deep.equal({
259
+ type: ['string', 'null'],
260
+ description: 'Name of the actor'
261
+ });
262
+ expect(schema.properties.age).to.deep.equal({
263
+ type: 'integer',
264
+ description: 'Age of the actor'
265
+ });
266
+ expect(schema.properties.sex).to.deep.equal({
267
+ type: ['string', 'null'],
268
+ description: 'Gender',
269
+ enum: ['m', 'f', null],
270
+ default: 'm'
271
+ });
272
+ });
273
+
274
+ it('should not apply descriptor as nested descriptions for objects', () => {
275
+ const example = { user: { name: 'Alice', age: 30 } };
276
+ const descriptions = {
277
+ user: { description: 'User details', required: false }
278
+ };
279
+ const schema = generateJsonSchema(example, descriptions);
280
+
281
+ expect(schema.required).to.deep.equal([]);
282
+ expect(schema.properties.user.description).to.equal('User details');
283
+ expect(schema.properties.user.properties.name).to.deep.equal({ type: 'string' });
284
+ expect(schema.properties.user.properties.age).to.deep.equal({ type: 'integer' });
285
+ });
286
+
287
+ it('should pass nested descriptions correctly for objects', () => {
288
+ const example = { user: { name: 'Alice', age: 30 } };
289
+ const descriptions = {
290
+ user: { name: 'User name', age: 'User age' }
291
+ };
292
+ const schema = generateJsonSchema(example, descriptions);
293
+
294
+ expect(schema.properties.user.properties.name).to.deep.equal({
295
+ type: 'string',
296
+ description: 'User name'
297
+ });
298
+ expect(schema.properties.user.properties.age).to.deep.equal({
299
+ type: 'integer',
300
+ description: 'User age'
301
+ });
302
+ });
303
+
304
+ it('should handle array descriptions in array format', () => {
305
+ const example = {
306
+ countries: [{ name: 'France', capital: 'Paris' }]
307
+ };
308
+ const descriptions = {
309
+ countries: [{ name: 'Country name', capital: 'Capital city' }]
310
+ };
311
+ const schema = generateJsonSchema(example, descriptions);
312
+
313
+ expect(schema.properties.countries.type).to.equal('array');
314
+ expect(schema.properties.countries.items.properties.name.description).to.equal('Country name');
315
+ expect(schema.properties.countries.items.properties.capital.description).to.equal('Capital city');
316
+ });
317
+
318
+ it('should handle descriptor for array field itself', () => {
319
+ const example = { tags: ['admin'] };
320
+ const descriptions = {
321
+ tags: { description: 'User tags', required: false }
322
+ };
323
+ const schema = generateJsonSchema(example, descriptions);
324
+
325
+ expect(schema.properties.tags.description).to.equal('User tags');
326
+ expect(schema.required).to.deep.equal([]);
327
+ });
328
+
329
+ it('should not double-add null to type', () => {
330
+ const example = { status: 'active' };
331
+ const descriptions = {
332
+ status: { required: false, enum: ['active', null] }
333
+ };
334
+ const schema = generateJsonSchema(example, descriptions);
335
+
336
+ const nullCount = schema.properties.status.type.filter(t => t === 'null').length;
337
+ expect(nullCount).to.equal(1);
338
+ });
339
+ });
340
+
184
341
  describe('ModelMix JSON Output', () => {
185
342
  let model;
186
343
 
@@ -291,5 +448,33 @@ describe('JSON Schema and Structured Output Tests', () => {
291
448
  expect(error.message).to.include('JSON');
292
449
  }
293
450
  });
451
+
452
+ it('should auto-wrap top-level array and unwrap on return', async () => {
453
+ model.gpt41().addText('List 3 countries');
454
+
455
+ nock('https://api.openai.com')
456
+ .post('/v1/chat/completions')
457
+ .reply(200, {
458
+ choices: [{
459
+ message: {
460
+ role: 'assistant',
461
+ content: JSON.stringify({
462
+ out: [
463
+ { name: 'France' },
464
+ { name: 'Germany' },
465
+ { name: 'Spain' }
466
+ ]
467
+ })
468
+ }
469
+ }]
470
+ });
471
+
472
+ const result = await model.json([{ name: 'France' }]);
473
+
474
+ expect(result).to.be.an('array');
475
+ expect(result).to.have.length(3);
476
+ expect(result[0]).to.have.property('name', 'France');
477
+ expect(result[2]).to.have.property('name', 'Spain');
478
+ });
294
479
  });
295
480
  });