modelmix 4.5.1 → 4.5.2

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/http-client.js ADDED
@@ -0,0 +1,80 @@
1
+ const { Readable } = require('stream');
2
+
3
+ function headersToObject(headers) {
4
+ return Object.fromEntries(headers.entries());
5
+ }
6
+
7
+ async function parseResponseBody(response) {
8
+ try {
9
+ return await response.json();
10
+ } catch {
11
+ try {
12
+ return await response.text();
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+ }
18
+
19
+ async function parseJsonBody(response) {
20
+ const text = await response.text();
21
+ if (!text) return null;
22
+ return JSON.parse(text);
23
+ }
24
+
25
+ async function buildHttpError(url, response) {
26
+ const details = await parseResponseBody(response);
27
+ const error = new Error(`Request to ${url} failed with status code ${response.status}`);
28
+ error.isHttpError = true;
29
+ error.statusCode = response.status;
30
+ error.details = details;
31
+ error.response = { status: response.status, data: details };
32
+ return error;
33
+ }
34
+
35
+ async function fetchJsonResponse(url, { method = 'POST', headers = {}, body } = {}) {
36
+ const response = await fetch(url, { method, headers, body });
37
+ if (!response.ok) {
38
+ throw await buildHttpError(url, response);
39
+ }
40
+ const data = await parseJsonBody(response);
41
+ return {
42
+ data,
43
+ status: response.status,
44
+ headers: headersToObject(response.headers)
45
+ };
46
+ }
47
+
48
+ async function fetchBinaryResponse(url, { method = 'GET', headers = {}, body } = {}) {
49
+ const response = await fetch(url, { method, headers, body });
50
+ if (!response.ok) {
51
+ throw await buildHttpError(url, response);
52
+ }
53
+ const data = Buffer.from(await response.arrayBuffer());
54
+ return {
55
+ data,
56
+ status: response.status,
57
+ headers: headersToObject(response.headers)
58
+ };
59
+ }
60
+
61
+ async function fetchStreamResponse(url, { method = 'POST', headers = {}, body } = {}) {
62
+ const response = await fetch(url, { method, headers, body });
63
+ if (!response.ok) {
64
+ throw await buildHttpError(url, response);
65
+ }
66
+ if (!response.body) {
67
+ throw new Error(`Request to ${url} did not return a readable stream`);
68
+ }
69
+ return {
70
+ data: Readable.fromWeb(response.body),
71
+ status: response.status,
72
+ headers: headersToObject(response.headers)
73
+ };
74
+ }
75
+
76
+ module.exports = {
77
+ fetchJsonResponse,
78
+ fetchBinaryResponse,
79
+ fetchStreamResponse
80
+ };
package/index.js CHANGED
@@ -2,7 +2,6 @@ const fs = require('fs');
2
2
  const fileType = require('file-type');
3
3
  const detectFileTypeFromBuffer = fileType.fileTypeFromBuffer || fileType.fromBuffer;
4
4
  const { inspect } = require('util');
5
- const { Readable } = require('stream');
6
5
  const log = require('lemonlog')('ModelMix');
7
6
  const Bottleneck = require('bottleneck');
8
7
  const path = require('path');
@@ -11,79 +10,16 @@ const generateJsonSchema = require('./schema');
11
10
  const { Client } = require("@modelcontextprotocol/sdk/client/index.js");
12
11
  const { StdioClientTransport } = require("@modelcontextprotocol/sdk/client/stdio.js");
13
12
  const { MCPToolsManager } = require('./mcp-tools');
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
- }
13
+ const {
14
+ stripContentTypeHeader,
15
+ createMultipartFormData,
16
+ buildRequestBodyAndHeaders
17
+ } = require('./multipart');
18
+ const {
19
+ fetchJsonResponse,
20
+ fetchBinaryResponse,
21
+ fetchStreamResponse
22
+ } = require('./http-client');
87
23
 
88
24
  const DEFAULT_RETRYABLE_STATUS_CODES = [408, 425, 429, 500, 502, 503, 504, 529];
89
25
 
@@ -1389,10 +1325,25 @@ class MixCustom {
1389
1325
  return MixOpenAI.convertMessages(messages, config);
1390
1326
  }
1391
1327
 
1328
+ static stripContentTypeHeader(headers = {}) {
1329
+ return stripContentTypeHeader(headers);
1330
+ }
1331
+
1332
+ static createMultipartFormData({ fields = {}, files = [] } = {}) {
1333
+ return createMultipartFormData({ fields, files });
1334
+ }
1335
+
1336
+ static buildRequestBodyAndHeaders(options, headers) {
1337
+ return buildRequestBodyAndHeaders(options, headers);
1338
+ }
1339
+
1392
1340
  async create({ config = {}, options = {} } = {}) {
1393
1341
  try {
1342
+ if (Array.isArray(options.messages)) {
1343
+ options.messages = this.convertMessages(options.messages, config);
1344
+ }
1394
1345
 
1395
- options.messages = this.convertMessages(options.messages, config);
1346
+ const request = buildRequestBodyAndHeaders(options, this.headers);
1396
1347
 
1397
1348
  // debug level 4 (verbose): Full request details
1398
1349
  if (config.debug >= 4) {
@@ -1404,20 +1355,20 @@ class MixCustom {
1404
1355
  console.log(ModelMix.formatJSON(configToLog));
1405
1356
 
1406
1357
  console.log('\n[OPTIONS]');
1407
- console.log(ModelMix.formatJSON(options));
1358
+ console.log(ModelMix.formatJSON(request.options));
1408
1359
  }
1409
1360
 
1410
1361
  if (options.stream) {
1411
1362
  return this.processStream(await fetchStreamResponse(this.config.url, {
1412
1363
  method: 'POST',
1413
- headers: this.headers,
1414
- body: JSON.stringify(options)
1364
+ headers: request.headers,
1365
+ body: request.body
1415
1366
  }));
1416
1367
  } else {
1417
1368
  return this.processResponse(await fetchJsonResponse(this.config.url, {
1418
1369
  method: 'POST',
1419
- headers: this.headers,
1420
- body: JSON.stringify(options)
1370
+ headers: request.headers,
1371
+ body: request.body
1421
1372
  }));
1422
1373
  }
1423
1374
  } catch (error) {
@@ -2236,6 +2187,7 @@ class MixAnthropic extends MixCustom {
2236
2187
 
2237
2188
  static extractMessage(data) {
2238
2189
  const content = Array.isArray(data?.content) ? data.content : [];
2190
+ const stopReason = data?.stop_reason;
2239
2191
 
2240
2192
  // Anthropic can return text in different positions depending on thinking/tool blocks.
2241
2193
  const textBlock = content.find(block => typeof block?.text === 'string' && block.text.trim().length > 0);
@@ -2243,8 +2195,12 @@ class MixAnthropic extends MixCustom {
2243
2195
  return textBlock.text;
2244
2196
  }
2245
2197
 
2198
+ // A tool_use turn can legitimately contain no text blocks.
2199
+ if (stopReason === 'tool_use') {
2200
+ return '';
2201
+ }
2202
+
2246
2203
  // Empty/non-text content is often due to safety refusal or token limits.
2247
- const stopReason = data?.stop_reason;
2248
2204
  const contentTypes = content.map(block => block?.type || 'unknown').join(', ') || 'none';
2249
2205
 
2250
2206
  if (stopReason === 'refusal') {
package/multipart.js ADDED
@@ -0,0 +1,111 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ function stripContentTypeHeader(headers = {}) {
5
+ const sanitizedHeaders = {};
6
+ for (const [name, value] of Object.entries(headers)) {
7
+ if (name.toLowerCase() !== 'content-type') {
8
+ sanitizedHeaders[name] = value;
9
+ }
10
+ }
11
+ return sanitizedHeaders;
12
+ }
13
+
14
+ function createMultipartFormData({ fields = {}, files = [] } = {}) {
15
+ if (typeof FormData !== 'function' || typeof Blob !== 'function') {
16
+ throw new Error('Native FormData/Blob are not available in this Node.js runtime');
17
+ }
18
+
19
+ const formData = new FormData();
20
+
21
+ for (const [name, value] of Object.entries(fields)) {
22
+ if (value === undefined || value === null) continue;
23
+ const normalizedValue = typeof value === 'string' ? value : JSON.stringify(value);
24
+ formData.append(name, normalizedValue);
25
+ }
26
+
27
+ for (const file of files) {
28
+ if (!file || !file.name) {
29
+ throw new Error('Each multipart file must include a "name" property');
30
+ }
31
+
32
+ let data = file.data;
33
+ let filename = file.filename;
34
+
35
+ if (file.path) {
36
+ const absolutePath = path.resolve(file.path);
37
+ data = fs.readFileSync(absolutePath);
38
+ filename = filename || path.basename(absolutePath);
39
+ }
40
+
41
+ if (typeof data === 'string') {
42
+ data = Buffer.from(data);
43
+ } else if (data instanceof ArrayBuffer) {
44
+ data = Buffer.from(data);
45
+ } else if (ArrayBuffer.isView(data)) {
46
+ data = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
47
+ }
48
+
49
+ if (!Buffer.isBuffer(data)) {
50
+ throw new Error(`Invalid multipart file data for "${file.name}" - expected Buffer, ArrayBuffer, typed array, string, or file path`);
51
+ }
52
+
53
+ const blob = new Blob([data], {
54
+ type: file.contentType || 'application/octet-stream'
55
+ });
56
+
57
+ formData.append(file.name, blob, filename || 'file');
58
+ }
59
+
60
+ return formData;
61
+ }
62
+
63
+ function buildRequestBodyAndHeaders(options, headers) {
64
+ if (options?.body instanceof FormData) {
65
+ const requestOptions = { ...options };
66
+ delete requestOptions.body;
67
+ return {
68
+ options: requestOptions,
69
+ body: options.body,
70
+ headers: stripContentTypeHeader(headers)
71
+ };
72
+ }
73
+
74
+ if (options?.multipart) {
75
+ const requestOptions = { ...options };
76
+ const multipartConfig = requestOptions.multipart || {};
77
+ delete requestOptions.multipart;
78
+
79
+ const serializedFields = {};
80
+ for (const [key, value] of Object.entries(requestOptions)) {
81
+ if (value === undefined) continue;
82
+ serializedFields[key] = value;
83
+ }
84
+
85
+ const body = createMultipartFormData({
86
+ fields: {
87
+ ...serializedFields,
88
+ ...(multipartConfig.fields || {})
89
+ },
90
+ files: multipartConfig.files || []
91
+ });
92
+
93
+ return {
94
+ options: requestOptions,
95
+ body,
96
+ headers: stripContentTypeHeader(headers)
97
+ };
98
+ }
99
+
100
+ return {
101
+ options,
102
+ body: JSON.stringify(options),
103
+ headers
104
+ };
105
+ }
106
+
107
+ module.exports = {
108
+ stripContentTypeHeader,
109
+ createMultipartFormData,
110
+ buildRequestBodyAndHeaders
111
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelmix",
3
- "version": "4.5.1",
3
+ "version": "4.5.2",
4
4
  "description": "🧬 Reliable interface with automatic fallback for AI LLMs.",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -50,7 +50,6 @@
50
50
  "@modelcontextprotocol/sdk": "^1.27.1",
51
51
  "bottleneck": "^2.19.5",
52
52
  "file-type": "^21.3.3",
53
- "form-data": "^4.0.4",
54
53
  "lemonlog": "^1.2.0",
55
54
  "ws": "^8.19.0"
56
55
  },
package/test/live.mcp.js CHANGED
@@ -60,8 +60,8 @@ describe('Live MCP Integration Tests', function () {
60
60
 
61
61
  describe('Basic MCP Tool Integration', function () {
62
62
 
63
- it('should use custom MCP tools with GPT-5.2', async function () {
64
- const model = ModelMix.new(setup).gpt52();
63
+ it('should use custom MCP tools with GPT-5.4', async function () {
64
+ const model = ModelMix.new(setup).gpt54();
65
65
 
66
66
  // Add custom calculator tool
67
67
  model.addTool({
package/test/live.test.js CHANGED
@@ -299,8 +299,8 @@ describe('Live Integration Tests', function () {
299
299
  expect(result.abilities).to.be.an('array');
300
300
  });
301
301
 
302
- it('should return structured JSON with KimiK2', async function () {
303
- const model = ModelMix.new(setup).kimiK2();
302
+ it('should return structured JSON with KimiK25 Thinking', async function () {
303
+ const model = ModelMix.new(setup).kimiK25think();
304
304
 
305
305
  model.addText('Generate information about a fictional vehicle.');
306
306
 
@@ -321,30 +321,8 @@ describe('Live Integration Tests', function () {
321
321
  expect(result.features).to.be.an('array');
322
322
  });
323
323
 
324
- it('should return structured JSON with GPT-OSS', async function () {
325
- const model = ModelMix.new(setup).gptOss();
326
-
327
- model.addText('Generate information about a fictional planet.');
328
-
329
- const result = await model.json({
330
- name: "Nova Prime",
331
- type: "Gas Giant",
332
- moons: ["Alpha", "Beta", "Gamma"],
333
- atmosphere: "Hydrogen and Helium"
334
- });
335
-
336
- console.log(`GPT-OSS JSON result:`, result);
337
-
338
- expect(result).to.be.an('object');
339
- expect(result).to.have.property('name');
340
- expect(result).to.have.property('type');
341
- expect(result).to.have.property('moons');
342
- expect(result).to.have.property('atmosphere');
343
- expect(result.moons).to.be.an('array');
344
- });
345
-
346
324
  it('should return structured JSON with Grok3Mini', async function () {
347
- const model = ModelMix.new(setup).grok3mini();
325
+ const model = ModelMix.new(setup).grok41();
348
326
 
349
327
  model.addText('Generate information about a fictional technology.');
350
328
 
@@ -0,0 +1,99 @@
1
+ const { expect } = require('chai');
2
+ const { MixCustom } = require('../index.js');
3
+
4
+ describe('Native multipart support', () => {
5
+ let originalFetch;
6
+
7
+ beforeEach(() => {
8
+ originalFetch = global.fetch;
9
+ });
10
+
11
+ afterEach(() => {
12
+ global.fetch = originalFetch;
13
+ });
14
+
15
+ it('should create FormData from fields and files', () => {
16
+ const formData = MixCustom.createMultipartFormData({
17
+ fields: {
18
+ model: 'whisper-1',
19
+ metadata: { source: 'test' }
20
+ },
21
+ files: [{
22
+ name: 'file',
23
+ data: Buffer.from('hello'),
24
+ filename: 'hello.txt',
25
+ contentType: 'text/plain'
26
+ }]
27
+ });
28
+
29
+ expect(formData).to.be.instanceOf(FormData);
30
+ expect(formData.get('model')).to.equal('whisper-1');
31
+ expect(formData.get('metadata')).to.equal('{"source":"test"}');
32
+
33
+ const uploadedFile = formData.get('file');
34
+ expect(uploadedFile).to.exist;
35
+ expect(uploadedFile.name).to.equal('hello.txt');
36
+ expect(uploadedFile.type).to.equal('text/plain');
37
+ });
38
+
39
+ it('should remove content-type header for multipart requests', () => {
40
+ const request = MixCustom.buildRequestBodyAndHeaders({
41
+ multipart: {
42
+ fields: { model: 'whisper-1' }
43
+ }
44
+ }, {
45
+ accept: 'application/json',
46
+ 'content-type': 'application/json',
47
+ authorization: 'Bearer test'
48
+ });
49
+
50
+ expect(request.body).to.be.instanceOf(FormData);
51
+ expect(request.headers).to.deep.equal({
52
+ accept: 'application/json',
53
+ authorization: 'Bearer test'
54
+ });
55
+ });
56
+
57
+ it('should send multipart body in create()', async () => {
58
+ let capturedRequest = null;
59
+
60
+ global.fetch = async (url, request) => {
61
+ capturedRequest = { url, request };
62
+ return new Response(JSON.stringify({
63
+ choices: [{
64
+ message: { content: 'ok' }
65
+ }]
66
+ }), {
67
+ status: 200,
68
+ headers: { 'content-type': 'application/json' }
69
+ });
70
+ };
71
+
72
+ const mix = new MixCustom({
73
+ config: {
74
+ url: 'https://api.example.com/v1/upload',
75
+ apiKey: 'token'
76
+ }
77
+ });
78
+
79
+ const result = await mix.create({
80
+ options: {
81
+ stream: false,
82
+ multipart: {
83
+ fields: { purpose: 'test' },
84
+ files: [{
85
+ name: 'file',
86
+ data: Buffer.from('sample'),
87
+ filename: 'sample.txt',
88
+ contentType: 'text/plain'
89
+ }]
90
+ }
91
+ }
92
+ });
93
+
94
+ expect(result.message).to.equal('ok');
95
+ expect(capturedRequest.url).to.equal('https://api.example.com/v1/upload');
96
+ expect(capturedRequest.request.body).to.be.instanceOf(FormData);
97
+ expect(capturedRequest.request.headers).to.not.have.property('content-type');
98
+ });
99
+ });