modelmix 4.5.1 → 4.5.4

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
@@ -87,7 +87,7 @@ console.log(ETH.price);
87
87
  ```javascript
88
88
  ModelMix.new()
89
89
  .gptOss()
90
- .kimiK2()
90
+ .kimiK25think()
91
91
  .deepseekR1()
92
92
  .hermes3()
93
93
  .addText('What is the capital of France?');
@@ -164,7 +164,6 @@ Here's a comprehensive list of available methods:
164
164
  | `sonarPro()` | Perplexity | sonar-pro | [\$3.00 / \$15.00][4] |
165
165
  | `hermes3()` | Lambda | Hermes-3-Llama-3.1-405B-FP8 | [\$0.80 / \$0.80][8] |
166
166
  | `qwen3()` | Together | Qwen3-235B-A22B-fp8-tput | [\$0.20 / \$0.60][7] |
167
- | `kimiK2()` | Together | Kimi-K2-Instruct | [\$1.00 / \$3.00][7] |
168
167
  | `kimiK25think()` | Together | Kimi-K2.5 | [\$0.50 / \$2.80][7] |
169
168
 
170
169
  [1]: https://platform.openai.com/docs/pricing "Pricing | OpenAI"
package/demo/free.js CHANGED
@@ -3,7 +3,7 @@ try { process.loadEnvFile(); } catch {}
3
3
 
4
4
  const ai = ModelMix.new({ config: { debug: 2 } })
5
5
  .gptOss()
6
- .kimiK2()
6
+ .kimiK25think()
7
7
  .deepseekR1()
8
8
  .hermes3()
9
9
  .addText('What is the capital of France?');
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
 
@@ -166,12 +102,6 @@ const MODEL_PRICING = {
166
102
  // Qwen3 (Together/Cerebras)
167
103
  'Qwen/Qwen3-235B-A22B-fp8-tput': [0.20, 0.60],
168
104
  'qwen-3-32b': [0.20, 0.60],
169
- // Kimi K2 (Together/Groq/OpenRouter)
170
- 'moonshotai/Kimi-K2-Instruct-0905': [1.00, 3.00],
171
- 'moonshotai/kimi-k2-instruct-0905': [1.00, 3.00],
172
- 'moonshotai/kimi-k2:free': [0, 0],
173
- 'moonshotai/Kimi-K2-Thinking': [1.00, 3.00],
174
- 'moonshotai/kimi-k2-thinking': [1.00, 3.00],
175
105
  // Kimi K2.5 (Together/Fireworks/OpenRouter)
176
106
  'moonshotai/Kimi-K2.5': [0.50, 2.80],
177
107
  'moonshotai/kimi-k2.5': [0.50, 2.80],
@@ -528,14 +458,6 @@ class ModelMix {
528
458
  return this;
529
459
  }
530
460
 
531
- kimiK2({ options = {}, config = {}, mix = {} } = {}) {
532
- mix = { ...this.mix, ...mix };
533
- if (mix.together) this.attach('moonshotai/Kimi-K2-Instruct-0905', new MixTogether({ options, config }));
534
- if (mix.groq) this.attach('moonshotai/kimi-k2-instruct-0905', new MixGroq({ options, config }));
535
- if (mix.openrouter) this.attach('moonshotai/kimi-k2:free', new MixOpenRouter({ options, config }));
536
- return this;
537
- }
538
-
539
461
  kimiK25think({ options = {}, config = {}, mix = { together: true } } = {}) {
540
462
  mix = { ...this.mix, ...mix };
541
463
  if (mix.together) this.attach('moonshotai/Kimi-K2.5', new MixTogether({ options, config }));
@@ -544,13 +466,6 @@ class ModelMix {
544
466
  return this;
545
467
  }
546
468
 
547
- kimiK2think({ options = {}, config = {}, mix = { together: true } } = {}) {
548
- mix = { ...this.mix, ...mix };
549
- if (mix.together) this.attach('moonshotai/Kimi-K2-Thinking', new MixTogether({ options, config }));
550
- if (mix.openrouter) this.attach('moonshotai/kimi-k2-thinking', new MixOpenRouter({ options, config }));
551
- return this;
552
- }
553
-
554
469
  lmstudio(model = 'lmstudio', { options = {}, config = {} } = {}) {
555
470
  return this.attach(model, new MixLMStudio({ options, config }));
556
471
  }
@@ -1389,10 +1304,25 @@ class MixCustom {
1389
1304
  return MixOpenAI.convertMessages(messages, config);
1390
1305
  }
1391
1306
 
1307
+ static stripContentTypeHeader(headers = {}) {
1308
+ return stripContentTypeHeader(headers);
1309
+ }
1310
+
1311
+ static createMultipartFormData({ fields = {}, files = [] } = {}) {
1312
+ return createMultipartFormData({ fields, files });
1313
+ }
1314
+
1315
+ static buildRequestBodyAndHeaders(options, headers) {
1316
+ return buildRequestBodyAndHeaders(options, headers);
1317
+ }
1318
+
1392
1319
  async create({ config = {}, options = {} } = {}) {
1393
1320
  try {
1321
+ if (Array.isArray(options.messages)) {
1322
+ options.messages = this.convertMessages(options.messages, config);
1323
+ }
1394
1324
 
1395
- options.messages = this.convertMessages(options.messages, config);
1325
+ const request = buildRequestBodyAndHeaders(options, this.headers);
1396
1326
 
1397
1327
  // debug level 4 (verbose): Full request details
1398
1328
  if (config.debug >= 4) {
@@ -1404,20 +1334,20 @@ class MixCustom {
1404
1334
  console.log(ModelMix.formatJSON(configToLog));
1405
1335
 
1406
1336
  console.log('\n[OPTIONS]');
1407
- console.log(ModelMix.formatJSON(options));
1337
+ console.log(ModelMix.formatJSON(request.options));
1408
1338
  }
1409
1339
 
1410
1340
  if (options.stream) {
1411
1341
  return this.processStream(await fetchStreamResponse(this.config.url, {
1412
1342
  method: 'POST',
1413
- headers: this.headers,
1414
- body: JSON.stringify(options)
1343
+ headers: request.headers,
1344
+ body: request.body
1415
1345
  }));
1416
1346
  } else {
1417
1347
  return this.processResponse(await fetchJsonResponse(this.config.url, {
1418
1348
  method: 'POST',
1419
- headers: this.headers,
1420
- body: JSON.stringify(options)
1349
+ headers: request.headers,
1350
+ body: request.body
1421
1351
  }));
1422
1352
  }
1423
1353
  } catch (error) {
@@ -2236,6 +2166,7 @@ class MixAnthropic extends MixCustom {
2236
2166
 
2237
2167
  static extractMessage(data) {
2238
2168
  const content = Array.isArray(data?.content) ? data.content : [];
2169
+ const stopReason = data?.stop_reason;
2239
2170
 
2240
2171
  // Anthropic can return text in different positions depending on thinking/tool blocks.
2241
2172
  const textBlock = content.find(block => typeof block?.text === 'string' && block.text.trim().length > 0);
@@ -2243,8 +2174,12 @@ class MixAnthropic extends MixCustom {
2243
2174
  return textBlock.text;
2244
2175
  }
2245
2176
 
2177
+ // A tool_use turn can legitimately contain no text blocks.
2178
+ if (stopReason === 'tool_use') {
2179
+ return '';
2180
+ }
2181
+
2246
2182
  // Empty/non-text content is often due to safety refusal or token limits.
2247
- const stopReason = data?.stop_reason;
2248
2183
  const contentTypes = content.map(block => block?.type || 'unknown').join(', ') || 'none';
2249
2184
 
2250
2185
  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.4",
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
  },
@@ -113,7 +113,7 @@ Thinking variants: append `think` — e.g. `opus46think()` `sonnet46think()` `so
113
113
  `scout()` `maverick()`
114
114
 
115
115
  ### Together
116
- `qwen3()` `kimiK2()` `kimiK2think()` `kimiK25think()` `gptOss()`
116
+ `qwen3()` `kimiK25think()` `gptOss()`
117
117
 
118
118
  ### MiniMax
119
119
  `minimaxM25()` `minimaxM21()` `minimaxM2()` `minimaxM2Stable()`
@@ -128,7 +128,7 @@ Thinking variants: append `think` — e.g. `opus46think()` `sonnet46think()` `so
128
128
  `GLM45()`
129
129
 
130
130
  ### Multi-provider (auto-fallback across free/paid tiers)
131
- `deepseekR1()` `hermes3()` `scout()` `maverick()` `kimiK2()` `GLM47()`
131
+ `deepseekR1()` `hermes3()` `scout()` `maverick()` `kimiK25think()` `GLM47()`
132
132
 
133
133
  ### Local
134
134
  `lmstudio()` — for LM Studio local models
@@ -356,7 +356,7 @@ For full debug output, also set: `DEBUG=ModelMix* node script.js`
356
356
  ```javascript
357
357
  const model = ModelMix.new()
358
358
  .gptOss()
359
- .kimiK2()
359
+ .kimiK25think()
360
360
  .deepseekR1()
361
361
  .hermes3()
362
362
  .addText("What is the capital of France?");
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
@@ -192,18 +192,6 @@ describe('Live Integration Tests', function () {
192
192
  expect(response.toLowerCase()).to.include('scout test successful');
193
193
  });
194
194
 
195
- it('should work with KimiK2 model', async function () {
196
- const model = ModelMix.new(setup).kimiK2();
197
-
198
- model.addText('Say "kimik2 test successful" and nothing else.');
199
-
200
- const response = await model.message();
201
- console.log(`KimiK2 response: ${response}`);
202
-
203
- expect(response).to.be.a('string');
204
- expect(response.toLowerCase()).to.include('kimik2 test successful');
205
- });
206
-
207
195
  it('should work with GPT-OSS model', async function () {
208
196
  const model = ModelMix.new(setup).gptOss();
209
197
 
@@ -299,8 +287,8 @@ describe('Live Integration Tests', function () {
299
287
  expect(result.abilities).to.be.an('array');
300
288
  });
301
289
 
302
- it('should return structured JSON with KimiK2', async function () {
303
- const model = ModelMix.new(setup).kimiK2();
290
+ it('should return structured JSON with KimiK25 Thinking', async function () {
291
+ const model = ModelMix.new(setup).kimiK25think();
304
292
 
305
293
  model.addText('Generate information about a fictional vehicle.');
306
294
 
@@ -311,7 +299,7 @@ describe('Live Integration Tests', function () {
311
299
  manufacturer: "Future Motors"
312
300
  });
313
301
 
314
- console.log(`KimiK2 JSON result:`, result);
302
+ console.log(`KimiK25 JSON result:`, result);
315
303
 
316
304
  expect(result).to.be.an('object');
317
305
  expect(result).to.have.property('name');
@@ -321,30 +309,8 @@ describe('Live Integration Tests', function () {
321
309
  expect(result.features).to.be.an('array');
322
310
  });
323
311
 
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
312
  it('should return structured JSON with Grok3Mini', async function () {
347
- const model = ModelMix.new(setup).grok3mini();
313
+ const model = ModelMix.new(setup).grok41();
348
314
 
349
315
  model.addText('Generate information about a fictional technology.');
350
316
 
@@ -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
+ });