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 +29 -0
- package/demo/images.js +1 -1
- package/demo/mcp-tools.js +16 -3
- package/index.js +169 -21
- package/package.json +1 -2
- package/test/fallback.test.js +99 -0
- package/test/live.mcp.js +30 -1
- package/test/live.test.js +4 -6
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 } }).
|
|
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
|
|
131
|
-
|
|
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
|
|
638
|
-
buffer =
|
|
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 = {
|
|
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
|
-
...
|
|
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
|
|
931
|
-
const
|
|
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
|
|
1411
|
+
return this.processStream(await fetchStreamResponse(this.config.url, {
|
|
1412
|
+
method: 'POST',
|
|
1273
1413
|
headers: this.headers,
|
|
1274
|
-
|
|
1414
|
+
body: JSON.stringify(options)
|
|
1275
1415
|
}));
|
|
1276
1416
|
} else {
|
|
1277
|
-
return this.processResponse(await
|
|
1278
|
-
|
|
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
|
|
1292
|
-
statusCode = error.
|
|
1293
|
-
errorMessage = `Request to ${this.config.url} failed with status code ${statusCode}`;
|
|
1294
|
-
errorDetails = error.
|
|
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
|
|
1587
|
-
|
|
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
|
|
2619
|
-
|
|
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.
|
|
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",
|
package/test/fallback.test.js
CHANGED
|
@@ -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
|
|
238
|
-
const model = ModelMix.new(setup).
|
|
239
|
-
model.
|
|
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
|
|
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 () {
|