modelmix 4.4.32 → 4.5.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/README.md CHANGED
@@ -475,6 +475,29 @@ const setup = {
475
475
 
476
476
  This integration ensures that your application respects API rate limits while maximizing throughput, providing a robust solution for managing multiple AI model interactions.
477
477
 
478
+ ## 🔁 Retry (Opt-In)
479
+
480
+ ModelMix supports optional intra-model retries for transient HTTP failures. When enabled, it retries the same provider before moving to fallback models.
481
+
482
+ ```javascript
483
+ const mix = ModelMix.new({
484
+ config: {
485
+ retry: {
486
+ enabled: true, // Default: false (opt-in)
487
+ retries: 2, // Extra attempts after first try
488
+ baseDelayMs: 500, // Exponential backoff base delay
489
+ maxDelayMs: 5000, // Backoff cap
490
+ retryableStatusCodes: [408, 425, 429, 500, 502, 503, 504, 529]
491
+ }
492
+ }
493
+ });
494
+ ```
495
+
496
+ Behavior summary:
497
+ - If retry is disabled (default), ModelMix keeps current behavior: immediate fallback to next model on failure.
498
+ - If retry is enabled, ModelMix retries the same model only for configured transient status codes.
499
+ - After retries are exhausted (or for non-retryable errors), ModelMix continues with normal fallback chain.
500
+
478
501
  ## 📚 ModelMix Class Overview
479
502
 
480
503
  ```javascript
@@ -496,6 +519,12 @@ new ModelMix(args = { options: {}, config: {} })
496
519
  - `reservoir`: Number of requests allowed in the reservoir period
497
520
  - `reservoirRefreshAmount`: How many requests are added when the reservoir refreshes
498
521
  - `reservoirRefreshInterval`: Reservoir refresh interval
522
+ - `retry`: Optional intra-model retry policy before fallback:
523
+ - `enabled`: Enables retry behavior (`false` by default)
524
+ - `retries`: Number of retries for retryable failures
525
+ - `baseDelayMs`: Initial backoff delay in milliseconds
526
+ - `maxDelayMs`: Maximum backoff delay in milliseconds
527
+ - `retryableStatusCodes`: HTTP status codes that should trigger retry
499
528
  - ...(Additional configuration parameters can be added as needed)
500
529
 
501
530
  **Methods**
package/demo/images.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { ModelMix } from '../index.js';
2
2
  try { process.loadEnvFile(); } catch {}
3
3
 
4
- const model = ModelMix.new({ config: { max_history: 2, debug: 2 } }).maverick()
4
+ const model = ModelMix.new({ config: { max_history: 2, debug: 2 } }).scout()
5
5
  // model.addImageFromUrl('https://pbs.twimg.com/media/F6-GsjraAAADDGy?format=jpg');
6
6
  model.addImage('./img.png');
7
7
  model.addText('in one word, which is the main color of the image?');
package/demo/mcp-tools.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { ModelMix } from '../index.js';
2
2
  import fs from 'fs';
3
- import axios from 'axios';
4
3
  try { process.loadEnvFile(); } catch {}
5
4
 
6
5
  console.log('🧬 ModelMix - MCP Tools Demo with Callbacks');
@@ -127,8 +126,22 @@ async function example3() {
127
126
  }
128
127
  }, async ({ url, method = "GET" }) => {
129
128
  try {
130
- const response = await axios({ method, url, timeout: 5000 });
131
- return `HTTP ${method} ${url}\nStatus: ${response.status}\nResponse: ${JSON.stringify(response.data, null, 2)}`;
129
+ const controller = new AbortController();
130
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
131
+ const response = await fetch(url, { method, signal: controller.signal });
132
+ clearTimeout(timeoutId);
133
+ const contentType = response.headers.get('content-type') || '';
134
+ let data;
135
+ if (contentType.includes('application/json')) {
136
+ try {
137
+ data = await response.json();
138
+ } catch {
139
+ data = await response.text();
140
+ }
141
+ } else {
142
+ data = await response.text();
143
+ }
144
+ return `HTTP ${method} ${url}\nStatus: ${response.status}\nResponse: ${typeof data === 'string' ? data : JSON.stringify(data, null, 2)}`;
132
145
  } catch (error) {
133
146
  return `Error making HTTP request to ${url}: ${error.message}`;
134
147
  }
package/index.js CHANGED
@@ -1,8 +1,8 @@
1
- const axios = require('axios');
2
1
  const fs = require('fs');
3
2
  const fileType = require('file-type');
4
3
  const detectFileTypeFromBuffer = fileType.fileTypeFromBuffer || fileType.fromBuffer;
5
4
  const { inspect } = require('util');
5
+ const { Readable } = require('stream');
6
6
  const log = require('lemonlog')('ModelMix');
7
7
  const Bottleneck = require('bottleneck');
8
8
  const path = require('path');
@@ -12,6 +12,89 @@ const { Client } = require("@modelcontextprotocol/sdk/client/index.js");
12
12
  const { StdioClientTransport } = require("@modelcontextprotocol/sdk/client/stdio.js");
13
13
  const { MCPToolsManager } = require('./mcp-tools');
14
14
 
15
+ function headersToObject(headers) {
16
+ return Object.fromEntries(headers.entries());
17
+ }
18
+
19
+ async function parseResponseBody(response) {
20
+ try {
21
+ return await response.json();
22
+ } catch {
23
+ try {
24
+ return await response.text();
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+ }
30
+
31
+ async function parseJsonBody(response) {
32
+ const text = await response.text();
33
+ if (!text) return null;
34
+ return JSON.parse(text);
35
+ }
36
+
37
+ async function buildHttpError(url, response) {
38
+ const details = await parseResponseBody(response);
39
+ const error = new Error(`Request to ${url} failed with status code ${response.status}`);
40
+ error.isHttpError = true;
41
+ error.statusCode = response.status;
42
+ error.details = details;
43
+ error.response = { status: response.status, data: details };
44
+ return error;
45
+ }
46
+
47
+ async function fetchJsonResponse(url, { method = 'POST', headers = {}, body } = {}) {
48
+ const response = await fetch(url, { method, headers, body });
49
+ if (!response.ok) {
50
+ throw await buildHttpError(url, response);
51
+ }
52
+ const data = await parseJsonBody(response);
53
+ return {
54
+ data,
55
+ status: response.status,
56
+ headers: headersToObject(response.headers)
57
+ };
58
+ }
59
+
60
+ async function fetchBinaryResponse(url, { method = 'GET', headers = {}, body } = {}) {
61
+ const response = await fetch(url, { method, headers, body });
62
+ if (!response.ok) {
63
+ throw await buildHttpError(url, response);
64
+ }
65
+ const data = Buffer.from(await response.arrayBuffer());
66
+ return {
67
+ data,
68
+ status: response.status,
69
+ headers: headersToObject(response.headers)
70
+ };
71
+ }
72
+
73
+ async function fetchStreamResponse(url, { method = 'POST', headers = {}, body } = {}) {
74
+ const response = await fetch(url, { method, headers, body });
75
+ if (!response.ok) {
76
+ throw await buildHttpError(url, response);
77
+ }
78
+ if (!response.body) {
79
+ throw new Error(`Request to ${url} did not return a readable stream`);
80
+ }
81
+ return {
82
+ data: Readable.fromWeb(response.body),
83
+ status: response.status,
84
+ headers: headersToObject(response.headers)
85
+ };
86
+ }
87
+
88
+ const DEFAULT_RETRYABLE_STATUS_CODES = [408, 425, 429, 500, 502, 503, 504, 529];
89
+
90
+ function getErrorStatusCode(error) {
91
+ return error?.statusCode ?? error?.response?.status ?? error?.response?.statusCode ?? null;
92
+ }
93
+
94
+ function sleep(ms) {
95
+ return new Promise(resolve => setTimeout(resolve, ms));
96
+ }
97
+
15
98
  // Pricing per 1M tokens: [input, output] in USD
16
99
  // Based on provider pricing pages linked in README
17
100
  const MODEL_PRICING = {
@@ -128,6 +211,13 @@ class ModelMix {
128
211
  max_history: 0, // 0=no history (stateless), N=keep last N messages, -1=unlimited
129
212
  debug: 0, // 0=silent, 1=minimal, 2=readable summary, 3=full (no truncate), 4=verbose (raw details)
130
213
  bottleneck: defaultBottleneckConfig,
214
+ retry: {
215
+ enabled: false,
216
+ retries: 2,
217
+ baseDelayMs: 500,
218
+ maxDelayMs: 5000,
219
+ retryableStatusCodes: [...DEFAULT_RETRYABLE_STATUS_CODES]
220
+ },
131
221
  roundRobin: false, // false=fallback mode, true=round robin rotation
132
222
  ...config
133
223
  }
@@ -417,7 +507,6 @@ class ModelMix {
417
507
  }
418
508
  maverick({ options = {}, config = {}, mix = {} } = {}) {
419
509
  mix = { ...this.mix, ...mix };
420
- if (mix.groq) this.attach('meta-llama/llama-4-maverick-17b-128e-instruct', new MixGroq({ options, config }));
421
510
  if (mix.together) this.attach('meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8', new MixTogether({ options, config }));
422
511
  if (mix.lambda) this.attach('llama-4-maverick-17b-128e-instruct-fp8', new MixLambda({ options, config }));
423
512
  return this;
@@ -634,8 +723,8 @@ class ModelMix {
634
723
 
635
724
  switch (content.source.type) {
636
725
  case 'url':
637
- const response = await axios.get(content.source.data, { responseType: 'arraybuffer' });
638
- buffer = Buffer.from(response.data);
726
+ const response = await fetchBinaryResponse(content.source.data);
727
+ buffer = response.data;
639
728
  mimeType = response.headers['content-type'];
640
729
  break;
641
730
 
@@ -869,8 +958,15 @@ class ModelMix {
869
958
  throw new Error("No user messages have been added. Use addText(prompt), addTextFromFile(filePath), addImage(filePath), or addImageFromUrl(url) to add a prompt.");
870
959
  }
871
960
 
872
- // Merge config to get final roundRobin value
873
- const finalConfig = { ...this.config, ...config };
961
+ // Merge config to get final roundRobin value and retry settings
962
+ const finalConfig = {
963
+ ...this.config,
964
+ ...config,
965
+ retry: {
966
+ ...(this.config.retry || {}),
967
+ ...(config.retry || {})
968
+ }
969
+ };
874
970
 
875
971
  // Try all models in order (first is primary, rest are fallbacks)
876
972
  const modelsToTry = this.models.map((model, index) => ({ model, index }));
@@ -900,9 +996,14 @@ class ModelMix {
900
996
  };
901
997
 
902
998
  const currentConfig = {
903
- ...this.config,
999
+ ...finalConfig,
904
1000
  ...providerInstance.config,
905
1001
  ...config,
1002
+ retry: {
1003
+ ...(finalConfig.retry || {}),
1004
+ ...(providerInstance.config?.retry || {}),
1005
+ ...(config.retry || {})
1006
+ }
906
1007
  };
907
1008
 
908
1009
  if (currentConfig.debug >= 1) {
@@ -927,8 +1028,46 @@ class ModelMix {
927
1028
  providerInstance.streamCallback = this.streamCallback;
928
1029
  }
929
1030
 
930
- const startTime = Date.now();
931
- const result = await providerInstance.create({ options: currentOptions, config: currentConfig });
1031
+ const retryConfig = currentConfig.retry || {};
1032
+ const retries = retryConfig.enabled ? Math.max(0, retryConfig.retries || 0) : 0;
1033
+ const baseDelayMs = Math.max(0, retryConfig.baseDelayMs || 0);
1034
+ const maxDelayMs = Math.max(baseDelayMs, retryConfig.maxDelayMs || baseDelayMs);
1035
+ const retryableStatusCodes = new Set(
1036
+ Array.isArray(retryConfig.retryableStatusCodes) && retryConfig.retryableStatusCodes.length > 0
1037
+ ? retryConfig.retryableStatusCodes
1038
+ : DEFAULT_RETRYABLE_STATUS_CODES
1039
+ );
1040
+
1041
+ let attempt = 0;
1042
+ let result;
1043
+ let startTime = 0;
1044
+
1045
+ while (true) {
1046
+ try {
1047
+ startTime = Date.now();
1048
+ result = await providerInstance.create({ options: currentOptions, config: currentConfig });
1049
+ break;
1050
+ } catch (attemptError) {
1051
+ const statusCode = getErrorStatusCode(attemptError);
1052
+ const isRetryable = retryableStatusCodes.has(statusCode);
1053
+ const canRetry = attempt < retries && isRetryable;
1054
+
1055
+ if (!canRetry) {
1056
+ throw attemptError;
1057
+ }
1058
+
1059
+ if (currentConfig.debug >= 1) {
1060
+ const nextAttempt = attempt + 2;
1061
+ const totalAttempts = retries + 1;
1062
+ console.log(`↺ Retrying [${currentModelKey}] due to status ${statusCode} (${nextAttempt}/${totalAttempts})`);
1063
+ }
1064
+
1065
+ const delay = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
1066
+ await sleep(delay);
1067
+ attempt += 1;
1068
+ }
1069
+ }
1070
+
932
1071
  const elapsedMs = Date.now() - startTime;
933
1072
 
934
1073
  if (result.tokens) {
@@ -1269,13 +1408,16 @@ class MixCustom {
1269
1408
  }
1270
1409
 
1271
1410
  if (options.stream) {
1272
- return this.processStream(await axios.post(this.config.url, options, {
1411
+ return this.processStream(await fetchStreamResponse(this.config.url, {
1412
+ method: 'POST',
1273
1413
  headers: this.headers,
1274
- responseType: 'stream'
1414
+ body: JSON.stringify(options)
1275
1415
  }));
1276
1416
  } else {
1277
- return this.processResponse(await axios.post(this.config.url, options, {
1278
- headers: this.headers
1417
+ return this.processResponse(await fetchJsonResponse(this.config.url, {
1418
+ method: 'POST',
1419
+ headers: this.headers,
1420
+ body: JSON.stringify(options)
1279
1421
  }));
1280
1422
  }
1281
1423
  } catch (error) {
@@ -1288,10 +1430,12 @@ class MixCustom {
1288
1430
  let statusCode = null;
1289
1431
  let errorDetails = null;
1290
1432
 
1291
- if (error.isAxiosError) {
1292
- statusCode = error.response ? error.response.status : null;
1293
- errorMessage = `Request to ${this.config.url} failed with status code ${statusCode}`;
1294
- errorDetails = error.response ? error.response.data : null;
1433
+ if (error?.isHttpError || error?.response || typeof error?.statusCode === 'number') {
1434
+ statusCode = error.statusCode ?? error.response?.status ?? null;
1435
+ errorMessage = error.message || `Request to ${this.config.url} failed with status code ${statusCode}`;
1436
+ errorDetails = error.details ?? error.response?.data ?? null;
1437
+ } else if (error?.message) {
1438
+ errorMessage = error.message;
1295
1439
  }
1296
1440
 
1297
1441
  const formattedError = {
@@ -1583,8 +1727,10 @@ class MixOpenAIResponses extends MixOpenAI {
1583
1727
 
1584
1728
  const responsesUrl = this.config.url.replace('/chat/completions', '/responses');
1585
1729
  const request = MixOpenAIResponses.buildResponsesRequest(options, config);
1586
- const response = await axios.post(responsesUrl, request, {
1587
- headers: this.headers
1730
+ const response = await fetchJsonResponse(responsesUrl, {
1731
+ method: 'POST',
1732
+ headers: this.headers,
1733
+ body: JSON.stringify(request)
1588
1734
  });
1589
1735
 
1590
1736
  return MixOpenAIResponses.processResponsesResponse(response);
@@ -2615,8 +2761,10 @@ class MixGoogle extends MixCustom {
2615
2761
  if (options.stream) {
2616
2762
  throw new Error('Stream is not supported for Gemini');
2617
2763
  } else {
2618
- return this.processResponse(await axios.post(fullUrl, payload, {
2619
- headers: this.headers
2764
+ return this.processResponse(await fetchJsonResponse(fullUrl, {
2765
+ method: 'POST',
2766
+ headers: this.headers,
2767
+ body: JSON.stringify(payload)
2620
2768
  }));
2621
2769
  }
2622
2770
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelmix",
3
- "version": "4.4.32",
3
+ "version": "4.5.0",
4
4
  "description": "🧬 Reliable interface with automatic fallback for AI LLMs.",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -48,7 +48,6 @@
48
48
  "homepage": "https://github.com/clasen/ModelMix#readme",
49
49
  "dependencies": {
50
50
  "@modelcontextprotocol/sdk": "^1.27.1",
51
- "axios": "^1.13.5",
52
51
  "bottleneck": "^2.19.5",
53
52
  "file-type": "^21.3.3",
54
53
  "form-data": "^4.0.4",
@@ -263,6 +263,105 @@ describe('Provider Fallback Chain Tests', () => {
263
263
  });
264
264
  });
265
265
 
266
+ describe('Retry Opt-In Before Fallback', () => {
267
+ it('should keep immediate fallback when retry is disabled', async () => {
268
+ const model = ModelMix.new({
269
+ config: { debug: false, retry: { enabled: false } }
270
+ });
271
+
272
+ model.gpt5mini().sonnet46().addText('Hello');
273
+
274
+ nock('https://api.openai.com')
275
+ .post('/v1/chat/completions')
276
+ .reply(503, { error: 'Service unavailable' });
277
+
278
+ nock('https://api.anthropic.com')
279
+ .post('/v1/messages')
280
+ .reply(200, {
281
+ content: [{
282
+ type: 'text',
283
+ text: 'Fallback without retry'
284
+ }]
285
+ });
286
+
287
+ const response = await model.message();
288
+ expect(response).to.include('Fallback without retry');
289
+ });
290
+
291
+ it('should retry same provider on retryable errors when enabled', async () => {
292
+ const model = ModelMix.new({
293
+ config: { debug: false, retry: { enabled: true, retries: 2, baseDelayMs: 0, maxDelayMs: 0 } }
294
+ });
295
+
296
+ model.gpt5mini().addText('Hello');
297
+
298
+ nock('https://api.openai.com')
299
+ .post('/v1/chat/completions')
300
+ .reply(503, { error: 'Temporary outage' })
301
+ .post('/v1/chat/completions')
302
+ .reply(200, {
303
+ choices: [{
304
+ message: {
305
+ role: 'assistant',
306
+ content: 'Recovered after retry'
307
+ }
308
+ }]
309
+ });
310
+
311
+ const response = await model.message();
312
+ expect(response).to.include('Recovered after retry');
313
+ });
314
+
315
+ it('should skip retry and fallback immediately on non-retryable errors', async () => {
316
+ const model = ModelMix.new({
317
+ config: { debug: false, retry: { enabled: true, retries: 2, baseDelayMs: 0, maxDelayMs: 0 } }
318
+ });
319
+
320
+ model.gpt5mini().sonnet46().addText('Hello');
321
+
322
+ nock('https://api.openai.com')
323
+ .post('/v1/chat/completions')
324
+ .reply(401, { error: 'Unauthorized' });
325
+
326
+ nock('https://api.anthropic.com')
327
+ .post('/v1/messages')
328
+ .reply(200, {
329
+ content: [{
330
+ type: 'text',
331
+ text: 'Immediate fallback from non-retryable error'
332
+ }]
333
+ });
334
+
335
+ const response = await model.message();
336
+ expect(response).to.include('Immediate fallback from non-retryable error');
337
+ });
338
+
339
+ it('should fallback after retry attempts are exhausted', async () => {
340
+ const model = ModelMix.new({
341
+ config: { debug: false, retry: { enabled: true, retries: 1, baseDelayMs: 0, maxDelayMs: 0 } }
342
+ });
343
+
344
+ model.gpt5mini().sonnet46().addText('Hello');
345
+
346
+ nock('https://api.openai.com')
347
+ .post('/v1/chat/completions')
348
+ .times(2)
349
+ .reply(503, { error: 'Still unavailable' });
350
+
351
+ nock('https://api.anthropic.com')
352
+ .post('/v1/messages')
353
+ .reply(200, {
354
+ content: [{
355
+ type: 'text',
356
+ text: 'Fallback after exhausted retries'
357
+ }]
358
+ });
359
+
360
+ const response = await model.message();
361
+ expect(response).to.include('Fallback after exhausted retries');
362
+ });
363
+ });
364
+
266
365
  describe('Fallback Configuration', () => {
267
366
  it('should respect custom provider configurations in fallback', async () => {
268
367
  const model = ModelMix.new({
package/test/live.mcp.js CHANGED
@@ -9,6 +9,35 @@ const setup = {
9
9
  config: { debug: false, max_history: 3 }
10
10
  };
11
11
 
12
+ const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504, 529]);
13
+
14
+ function getStatusCode(error) {
15
+ return error?.statusCode ?? error?.response?.status ?? error?.response?.statusCode ?? null;
16
+ }
17
+
18
+ function isRetryableError(error) {
19
+ return RETRYABLE_STATUS_CODES.has(getStatusCode(error));
20
+ }
21
+
22
+ async function withRetry(task, { retries = 2, baseDelayMs = 1000 } = {}) {
23
+ let attempt = 0;
24
+ let lastError = null;
25
+ while (attempt <= retries) {
26
+ try {
27
+ return await task();
28
+ } catch (error) {
29
+ lastError = error;
30
+ if (attempt === retries || !isRetryableError(error)) {
31
+ throw error;
32
+ }
33
+ const backoffMs = baseDelayMs * Math.pow(2, attempt);
34
+ await new Promise(resolve => setTimeout(resolve, backoffMs));
35
+ attempt += 1;
36
+ }
37
+ }
38
+ throw lastError;
39
+ }
40
+
12
41
  describe('Live MCP Integration Tests', function () {
13
42
  // Increase timeout for real API calls
14
43
  this.timeout(60000);
@@ -96,7 +125,7 @@ describe('Live MCP Integration Tests', function () {
96
125
  model.addText('What time is it right now?');
97
126
 
98
127
  try {
99
- const response = await model.message();
128
+ const response = await withRetry(() => model.message(), { retries: 2, baseDelayMs: 1500 });
100
129
  console.log(`Claude Sonnet 4 with MCP tools: ${response}`);
101
130
 
102
131
  expect(response).to.be.a('string');
package/test/live.test.js CHANGED
@@ -1,8 +1,6 @@
1
1
  const { expect } = require('chai');
2
2
  const { ModelMix, MixOpenAI, MixAnthropic, MixGoogle } = require('../index.js');
3
3
  const nock = require('nock');
4
- const path = require('path');
5
- const fixturesPath = path.join(__dirname, 'fixtures');
6
4
 
7
5
  const blueSquareBase64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAMAAABHPGVmAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDkuMS1jMDAzIDc5Ljk2OTBhODdmYywgMjAyNS8wMy8wNi0yMDo1MDoxNiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIDI2LjkgKE1hY2ludG9zaCkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6REM2QzQ3NEQ2Q0I5MTFGMDlBRTVGQzcwQjMyMkY4MDciIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6REM2QzQ3NEU2Q0I5MTFGMDlBRTVGQzcwQjMyMkY4MDciPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDpEQzZDNDc0QjZDQjkxMUYwOUFFNUZDNzBCMzIyRjgwNyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpEQzZDNDc0QzZDQjkxMUYwOUFFNUZDNzBCMzIyRjgwNyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PvArxh0AAAAGUExURQAA/wAAAHtivz4AAAAjSURBVHja7MGBAAAAAMOg+VNf4QBVAQAAAAAAAAAAAI8JMAAndAABi7SX2gAAAABJRU5ErkJggg==';
8
6
 
@@ -234,9 +232,9 @@ describe('Live Integration Tests', function () {
234
232
 
235
233
  describe('Image Processing with JSON Output', function () {
236
234
 
237
- it('should process images and return JSON with Maverick', async function () {
238
- const model = ModelMix.new(setup).maverick();
239
- model.addImage(path.join(fixturesPath, 'img.png'))
235
+ it('should process images and return JSON with Scout', async function () {
236
+ const model = ModelMix.new(setup).scout();
237
+ model.addImageFromUrl(blueSquareBase64)
240
238
  .addText('Analyze this image and provide details in JSON format.');
241
239
 
242
240
  const result = await model.json({
@@ -251,7 +249,7 @@ describe('Live Integration Tests', function () {
251
249
  expect(result).to.have.property('color');
252
250
  expect(result).to.have.property('shape');
253
251
  expect(result).to.have.property('description');
254
- expect(result.color.toLowerCase()).to.include('blue');
252
+ expect(result.color).to.be.a('string').and.not.empty;
255
253
  });
256
254
 
257
255
  it('should process images and return JSON with Grok 4', async function () {