modelmix 5.1.20 → 5.2.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.
@@ -1,7 +1,279 @@
1
1
  const { expect } = require('chai');
2
- const { ModelMix } = require('../index.js');
2
+ const nock = require('nock');
3
+ const { ModelMix, MixDeepSeek, MixFireworks, MixOpenRouter } = require('../index.js');
3
4
 
4
5
  describe('DeepSeek Model Registration Tests', () => {
6
+ it('registers V4 Pro 0813 through OpenRouter and preserves caller settings', () => {
7
+ const model = ModelMix.new();
8
+ expect(model.deepseekPro({
9
+ options: { temperature: 0.5 },
10
+ config: { apiKey: 'test-key', effort: 100 }
11
+ })).to.equal(model);
12
+
13
+ expect(model.models).to.have.length(1);
14
+ expect(model.models[0].key).to.equal('deepseek/deepseek-v4-pro-0813');
15
+ expect(model.models[0].provider).to.be.instanceOf(MixOpenRouter);
16
+ expect(model.models[0].provider.options.temperature).to.equal(0.5);
17
+ expect(model.models[0].provider.config.effort).to.equal(100);
18
+ });
19
+
20
+ describe('DeepSeek V4 Pro 0813 requests', () => {
21
+ afterEach(() => nock.cleanAll());
22
+
23
+ for (const [effort, level] of [[0, undefined], [20, 'low'], [60, 'high'], [100, 'max'], [-1, undefined]]) {
24
+ it(`sends the exact model ID through chain() at effort ${effort} and accounts for cached input`, async () => {
25
+ const scope = nock('https://openrouter.ai')
26
+ .post('/api/v1/chat/completions', body => {
27
+ expect(body.model).to.equal('deepseek/deepseek-v4-pro-0813');
28
+ expect(body.reasoning_effort).to.equal(level);
29
+ expect(body.thinking).to.deep.equal(effort === -1
30
+ ? undefined
31
+ : { type: effort === 0 ? 'disabled' : 'enabled' });
32
+ return true;
33
+ })
34
+ .reply(200, {
35
+ model: 'deepseek/deepseek-v4-pro-0813',
36
+ choices: [{ message: { role: 'assistant', content: 'ok' } }],
37
+ usage: {
38
+ prompt_tokens: 1000,
39
+ completion_tokens: 100,
40
+ prompt_tokens_details: { cached_tokens: 400 }
41
+ }
42
+ });
43
+ const model = ModelMix.new().chain(`deepseekPro@${effort}`).addText('Hello');
44
+
45
+ expect(await model.message()).to.equal('ok');
46
+ expect(scope.isDone()).to.equal(true);
47
+ expect(model.lastRaw.tokens).to.include({ input: 1000, cached: 400, output: 100 });
48
+ expect(model.lastRaw.tokens.cost).to.be.closeTo(0.000545952, 1e-12);
49
+ });
50
+ }
51
+ });
52
+
53
+ it('requires native credentials and accepts DEEPSEEK_API_KEY', () => {
54
+ const originalApiKey = process.env.DEEPSEEK_API_KEY;
55
+ try {
56
+ delete process.env.DEEPSEEK_API_KEY;
57
+ expect(() => new MixDeepSeek()).to.throw(/DEEPSEEK_API_KEY/);
58
+ process.env.DEEPSEEK_API_KEY = 'native-test-key';
59
+ const model = ModelMix.new().deepseekV41Flash({ mix: { deepseek: true, openrouter: false } });
60
+ expect(model.models).to.have.length(1);
61
+ expect(model.models[0].key).to.equal('deepseek-flash');
62
+ expect(model.models[0].provider.config.apiKey).to.equal('native-test-key');
63
+ } finally {
64
+ if (originalApiKey === undefined) delete process.env.DEEPSEEK_API_KEY;
65
+ else process.env.DEEPSEEK_API_KEY = originalApiKey;
66
+ }
67
+ });
68
+
69
+ it('registers DeepSeek V4.1 Flash through OpenRouter and preserves caller settings', () => {
70
+ const model = ModelMix.new();
71
+ expect(model.deepseekV41Flash({
72
+ options: { temperature: 0.5 },
73
+ config: { effort: 100 }
74
+ })).to.equal(model);
75
+
76
+ expect(model.models).to.have.length(1);
77
+ expect(model.models[0].key).to.equal('deepseek/deepseek-v4.1-flash');
78
+ expect(model.models[0].provider).to.be.instanceOf(MixOpenRouter);
79
+ expect(model.models[0].provider.options.temperature).to.equal(0.5);
80
+ expect(model.models[0].provider.config.effort).to.equal(100);
81
+ });
82
+
83
+ describe('DeepSeek V4.1 Flash requests', () => {
84
+ afterEach(() => nock.cleanAll());
85
+
86
+ it('falls back from native DeepSeek through Fireworks to OpenRouter', async () => {
87
+ const native = nock('https://api.deepseek.com')
88
+ .post('/chat/completions', body => body.model === 'deepseek-flash')
89
+ .reply(503, { error: { message: 'Unavailable' } });
90
+ const fireworks = nock('https://api.fireworks.ai')
91
+ .post('/inference/v1/chat/completions')
92
+ .reply(503, { error: { message: 'Unavailable' } });
93
+ const openrouter = nock('https://openrouter.ai')
94
+ .post('/api/v1/chat/completions')
95
+ .reply(200, { choices: [{ message: { content: 'fallback' } }] });
96
+ const model = ModelMix.new({ config: { retry: { retries: 0 } } })
97
+ .deepseekV41Flash({
98
+ mix: { deepseek: true, fireworks: true, openrouter: true },
99
+ config: { apiKey: 'test-key' }
100
+ }).addText('Hello');
101
+
102
+ expect(await model.message()).to.equal('fallback');
103
+ expect(native.isDone()).to.equal(true);
104
+ expect(fireworks.isDone()).to.equal(true);
105
+ expect(openrouter.isDone()).to.equal(true);
106
+ });
107
+
108
+ for (const [effort, level] of [[0, undefined], [20, 'low'], [60, 'high'], [100, 'max'], [-1, undefined]]) {
109
+ it(`sends native DeepSeek effort ${effort} and reads native cache usage`, async () => {
110
+ const scope = nock('https://api.deepseek.com', {
111
+ reqheaders: { authorization: 'Bearer native-test-key' }
112
+ })
113
+ .post('/chat/completions', body => {
114
+ expect(body.model).to.equal('deepseek-flash');
115
+ expect(body.reasoning_effort).to.equal(level);
116
+ expect(body.thinking).to.deep.equal(effort === -1
117
+ ? undefined
118
+ : { type: effort === 0 ? 'disabled' : 'enabled' });
119
+ return true;
120
+ })
121
+ .reply(200, {
122
+ choices: [{ message: { role: 'assistant', content: 'ok', reasoning_content: 'Reasoning' } }],
123
+ usage: { prompt_tokens: 1000, completion_tokens: 100, prompt_cache_hit_tokens: 400 }
124
+ });
125
+ const model = ModelMix.new().deepseekV41Flash({
126
+ mix: { deepseek: true, openrouter: false },
127
+ config: { apiKey: 'native-test-key', effort }
128
+ }).addText('Hello');
129
+
130
+ expect(model.models).to.have.length(1);
131
+ expect(model.models[0].provider).to.be.instanceOf(MixDeepSeek);
132
+ expect(await model.message()).to.equal('ok');
133
+ expect(scope.isDone()).to.equal(true);
134
+ expect(model.lastRaw.tokens).to.include({ input: 1000, cached: 400, output: 100 });
135
+ expect(model.lastRaw.tokens.cost).to.be.closeTo(0.0003024, 1e-12);
136
+ });
137
+ }
138
+
139
+ it('preserves native reasoning across tool continuation and later turns', async () => {
140
+ const assistantMessage = {
141
+ role: 'assistant', content: null, reasoning_content: 'Use the lookup tool.',
142
+ tool_calls: [{ id: 'call_lookup', type: 'function', function: { name: 'lookup', arguments: '{}' } }]
143
+ };
144
+ const scope = nock('https://api.deepseek.com')
145
+ .post('/chat/completions')
146
+ .reply(200, { choices: [{ message: assistantMessage }] })
147
+ .post('/chat/completions', body => {
148
+ expect(body.messages.find(message => message.role === 'assistant')).to.deep.equal(assistantMessage);
149
+ expect(body.messages.find(message => message.role === 'tool')).to.include({ tool_call_id: 'call_lookup' });
150
+ return true;
151
+ })
152
+ .reply(200, { choices: [{ message: { role: 'assistant', content: 'Found', reasoning_content: 'Lookup complete.' } }] })
153
+ .post('/chat/completions', body => {
154
+ expect(body.messages.filter(message => message.role === 'assistant').map(message => message.reasoning_content))
155
+ .to.deep.equal(['Use the lookup tool.', 'Lookup complete.']);
156
+ return true;
157
+ })
158
+ .reply(200, { choices: [{ message: { role: 'assistant', content: 'Done' } }] });
159
+ const model = ModelMix.new().deepseekV41Flash({
160
+ mix: { deepseek: true, openrouter: false },
161
+ config: { apiKey: 'native-test-key' }
162
+ }).addTool({
163
+ name: 'lookup', description: 'Look up a value.',
164
+ inputSchema: { type: 'object', properties: {} }
165
+ }, () => 'value').addText('Look it up');
166
+
167
+ expect(await model.message()).to.equal('Found');
168
+ model.addText('Continue');
169
+ expect(await model.message()).to.equal('Done');
170
+ expect(scope.isDone()).to.equal(true);
171
+ });
172
+
173
+ it('streams native text without including empty reasoning or usage deltas', async () => {
174
+ const chunks = [
175
+ { choices: [{ delta: { reasoning_content: 'Reasoning' } }] },
176
+ { choices: [{ delta: { content: 'Hello' } }] },
177
+ { choices: [], usage: { prompt_tokens: 1000, completion_tokens: 100, prompt_cache_hit_tokens: 400 } }
178
+ ];
179
+ const scope = nock('https://api.deepseek.com')
180
+ .post('/chat/completions', body => body.stream === true)
181
+ .reply(200, chunks.map(chunk => `data: ${JSON.stringify(chunk)}\n\n`).join('') + 'data: [DONE]\n\n', {
182
+ 'content-type': 'text/event-stream'
183
+ });
184
+ const model = ModelMix.new().deepseekV41Flash({
185
+ mix: { deepseek: true, openrouter: false },
186
+ config: { apiKey: 'native-test-key' }
187
+ }).addText('Hello');
188
+ const deltas = [];
189
+ const result = await model.stream(({ delta }) => deltas.push(delta));
190
+
191
+ expect(result.message).to.equal('Hello');
192
+ expect(deltas.join('')).to.equal('Hello');
193
+ expect(result.tokens.cached).to.equal(400);
194
+ expect(scope.isDone()).to.equal(true);
195
+ });
196
+
197
+ it('uses only Fireworks when selected and accounts for its cached input pricing', async () => {
198
+ const scope = nock('https://api.fireworks.ai')
199
+ .post('/inference/v1/chat/completions', body => {
200
+ expect(body.model).to.equal('accounts/fireworks/models/deepseek-v4p1-flash');
201
+ expect(body.reasoning_effort).to.equal('max');
202
+ expect(body.thinking).to.deep.equal({ type: 'enabled' });
203
+ expect(body.temperature).to.equal(0.5);
204
+ return true;
205
+ })
206
+ .reply(200, {
207
+ choices: [{ message: { content: 'ok' } }],
208
+ usage: {
209
+ prompt_tokens: 1000,
210
+ completion_tokens: 100,
211
+ prompt_tokens_details: { cached_tokens: 400 }
212
+ }
213
+ });
214
+ const model = ModelMix.new().deepseekV41Flash({
215
+ mix: { fireworks: true, openrouter: false },
216
+ config: { effort: 100 },
217
+ options: { temperature: 0.5 }
218
+ }).addText('Hello');
219
+
220
+ expect(model.models).to.have.length(1);
221
+ expect(model.models[0].provider).to.be.instanceOf(MixFireworks);
222
+ expect(await model.message()).to.equal('ok');
223
+ expect(scope.isDone()).to.equal(true);
224
+ expect(model.lastRaw.tokens).to.include({ input: 1000, cached: 400, output: 100 });
225
+ expect(model.lastRaw.tokens.cost).to.be.closeTo(0.0002008, 1e-12);
226
+ });
227
+
228
+ it('falls back from Fireworks to OpenRouter when both are enabled', async () => {
229
+ const fireworks = nock('https://api.fireworks.ai')
230
+ .post('/inference/v1/chat/completions')
231
+ .reply(503, { error: { message: 'Unavailable' } });
232
+ const openrouter = nock('https://openrouter.ai')
233
+ .post('/api/v1/chat/completions', body => body.model === 'deepseek/deepseek-v4.1-flash')
234
+ .reply(200, { choices: [{ message: { content: 'fallback' } }] });
235
+ const model = ModelMix.new({ config: { retry: { retries: 0 } } })
236
+ .deepseekV41Flash({ mix: { fireworks: true, openrouter: true } })
237
+ .addText('Hello');
238
+
239
+ expect(model.models.map(({ key }) => key)).to.deep.equal([
240
+ 'accounts/fireworks/models/deepseek-v4p1-flash',
241
+ 'deepseek/deepseek-v4.1-flash'
242
+ ]);
243
+ expect(await model.message()).to.equal('fallback');
244
+ expect(fireworks.isDone()).to.equal(true);
245
+ expect(openrouter.isDone()).to.equal(true);
246
+ });
247
+
248
+ for (const [effort, level] of [[0, null], [20, 'low'], [60, 'high'], [100, 'max'], [-1, undefined]]) {
249
+ it(`sends chain effort ${effort} and accounts for cached input`, async () => {
250
+ const scope = nock('https://openrouter.ai')
251
+ .post('/api/v1/chat/completions', body => {
252
+ expect(body.model).to.equal('deepseek/deepseek-v4.1-flash');
253
+ expect(body.reasoning_effort).to.equal(level || undefined);
254
+ expect(body.thinking).to.deep.equal(level === undefined
255
+ ? undefined
256
+ : { type: level === null ? 'disabled' : 'enabled' });
257
+ return true;
258
+ })
259
+ .reply(200, {
260
+ choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
261
+ usage: {
262
+ prompt_tokens: 1000,
263
+ completion_tokens: 100,
264
+ prompt_tokens_details: { cached_tokens: 400 }
265
+ }
266
+ });
267
+
268
+ const model = ModelMix.new().chain(`deepseekV41Flash@${effort}`).addText('Hello');
269
+ expect(await model.message()).to.equal('ok');
270
+ expect(scope.isDone()).to.equal(true);
271
+ expect(model.lastRaw.tokens).to.include({ input: 1000, cached: 400, output: 100 });
272
+ expect(model.lastRaw.tokens.cost).to.be.closeTo(0.000156, 1e-12);
273
+ });
274
+ }
275
+ });
276
+
5
277
  it('should register Fireworks DeepSeek V4 Pro by default', () => {
6
278
  const model = ModelMix.new();
7
279
  model.deepseekV4Pro({ mix: { fireworks: true, openrouter: false } });
@@ -13,6 +13,63 @@ const {
13
13
  } = require('../index.js');
14
14
 
15
15
  describe('Provider Fallback Chain Tests', () => {
16
+
17
+ describe('Interrupted provider responses', () => {
18
+ const timeout = {
19
+ id: 'generation-timeout',
20
+ provider: 'Novita',
21
+ error: { code: 504, message: 'Upstream idle timeout exceeded', metadata: { error_type: 'timeout' } },
22
+ choices: [{ finish_reason: 'error', message: { content: '' } }]
23
+ };
24
+
25
+ it('rejects HTTP 200 error bodies with their status and generation details', async () => {
26
+ sinon.stub(global, 'fetch').resolves(new Response(JSON.stringify(timeout), { status: 200 }));
27
+ const model = ModelMix.new().deepseekV41Flash().addText('test');
28
+ let failure;
29
+ try { await model.raw(); } catch (error) { failure = error; }
30
+ expect(failure).to.include({ statusCode: 504, message: timeout.error.message });
31
+ expect(failure.details).to.include({ id: timeout.id, provider: 'Novita' });
32
+ expect(failure.details.error.metadata.error_type).to.equal('timeout');
33
+ });
34
+
35
+ it('retries HTTP 200 provider errors only under the configured policy', async () => {
36
+ const fetch = sinon.stub(global, 'fetch');
37
+ fetch.onFirstCall().resolves(new Response(JSON.stringify(timeout)));
38
+ fetch.onSecondCall().resolves(new Response(JSON.stringify({ choices: [{ finish_reason: 'stop', message: { content: 'recovered' } }] })));
39
+ const model = ModelMix.new({ config: { retry: { enabled: true, retries: 1, baseDelayMs: 0, maxDelayMs: 0 } } }).deepseekV41Flash().addText('test');
40
+ expect((await model.raw()).message).to.equal('recovered');
41
+ expect(fetch.callCount).to.equal(2);
42
+ });
43
+
44
+ it('falls back when a provider returns only reasoning without completing', async () => {
45
+ const fetch = sinon.stub(global, 'fetch');
46
+ fetch.onFirstCall().resolves(new Response(JSON.stringify({ choices: [{ finish_reason: null, message: { content: '', reasoning: 'partial' } }] })));
47
+ fetch.onSecondCall().resolves(new Response(JSON.stringify({ choices: [{ finish_reason: 'stop', message: { content: 'fallback' } }] })));
48
+ const model = ModelMix.new().deepseekV41Flash().gpt5mini().addText('test');
49
+ expect((await model.raw()).message).to.equal('fallback');
50
+ expect(fetch.callCount).to.equal(2);
51
+ });
52
+
53
+ it('rejects a streaming timeout instead of returning partial output', async () => {
54
+ const chunks = [
55
+ { choices: [{ delta: { content: 'partial' } }] },
56
+ timeout
57
+ ];
58
+ sinon.stub(global, 'fetch').resolves(new Response(chunks.map(chunk => `data: ${JSON.stringify(chunk)}\n\n`).join(''), { headers: { 'content-type': 'text/event-stream' } }));
59
+ const model = ModelMix.new().deepseekV41Flash().addText('test');
60
+ let failure;
61
+ try { await model.stream(() => {}); } catch (error) { failure = error; }
62
+ expect(failure).to.include({ statusCode: 504, message: timeout.error.message });
63
+ });
64
+
65
+ it('rejects an abruptly ended stream even after some text arrived', async () => {
66
+ sinon.stub(global, 'fetch').resolves(new Response('data: {"choices":[{"delta":{"content":"partial"},"finish_reason":null}]}\n\n'));
67
+ let failure;
68
+ try { await ModelMix.new().deepseekV41Flash().addText('test').stream(() => {}); } catch (error) { failure = error; }
69
+ expect(failure).to.include({ statusCode: 502 });
70
+ expect(failure.message).to.include('without completing');
71
+ });
72
+ });
16
73
 
17
74
  // Setup test hooks
18
75
  if (global.setupTestHooks) {
@@ -0,0 +1,55 @@
1
+ const { expect } = require('chai');
2
+ const sinon = require('sinon');
3
+ const { MixGoogle, ModelMix } = require('../index');
4
+
5
+ describe('Google message and JSON contracts', () => {
6
+ afterEach(() => sinon.restore());
7
+
8
+ it('converts string user and assistant messages without changing their inputs', () => {
9
+ const messages = [
10
+ { role: 'user', content: 'Request' },
11
+ { role: 'assistant', content: 'Answer' },
12
+ { role: 'user', content: [{ type: 'text', text: 'Follow-up' }] }
13
+ ];
14
+ expect(MixGoogle.convertMessages(messages)).to.deep.equal([
15
+ { role: 'user', parts: [{ text: 'Request' }] },
16
+ { role: 'model', parts: [{ text: 'Answer' }] },
17
+ { role: 'user', parts: [{ text: 'Follow-up' }] }
18
+ ]);
19
+ expect(messages[0]).to.deep.equal({ role: 'user', content: 'Request' });
20
+ });
21
+
22
+ it('sends child text messages and JSON mode through the real Google adapter', async () => {
23
+ const bodies = [];
24
+ sinon.stub(global, 'fetch').callsFake(async (_url, input) => {
25
+ bodies.push(JSON.parse(input.body));
26
+ return new Response(JSON.stringify({
27
+ candidates: [{ content: { parts: [{ text: '{"ok":true}' }] } }]
28
+ }), { status: 200 });
29
+ });
30
+ const worker = ModelMix.new({ config: { apiKey: 'test-key' } }).gemini38flash();
31
+ const result = await ModelMix.new().use({
32
+ name: 'child-json',
33
+ execute: context => context.invoke({
34
+ model: worker,
35
+ system: 'Return JSON.',
36
+ messages: [{ role: 'user', content: 'Evaluate this response.' }],
37
+ options: { response_format: { type: 'json_object' } },
38
+ plugins: 'none'
39
+ })
40
+ }).addText('Task').json();
41
+ expect(result).to.deep.equal({ ok: true });
42
+ expect(bodies[0].contents).to.deep.equal([
43
+ { role: 'user', parts: [{ text: 'Evaluate this response.' }] }
44
+ ]);
45
+ expect(bodies[0].generationConfig.responseMimeType).to.equal('application/json');
46
+ });
47
+
48
+ it('combines answer text parts without including thought parts', () => {
49
+ expect(MixGoogle.extractMessage({ candidates: [{ content: { parts: [
50
+ { thought: true, text: 'Internal analysis' },
51
+ { text: '{"ok":' },
52
+ { text: 'true}' }
53
+ ] } }] })).to.equal('{"ok":true}');
54
+ });
55
+ });
@@ -629,7 +629,7 @@ describe('Conversation History Tests', () => {
629
629
  expect(model.messages).to.have.length(2);
630
630
  });
631
631
 
632
- it('should handle empty assistant response gracefully', async () => {
632
+ it('should reject an empty assistant response without adding it to history', async () => {
633
633
  const model = ModelMix.new({
634
634
  config: { debug: false, max_history: 10 }
635
635
  });
@@ -643,9 +643,12 @@ describe('Conversation History Tests', () => {
643
643
  }]
644
644
  });
645
645
 
646
- const response = await model.message();
647
- // Empty string is falsy, so assistant message should NOT be added
648
- expect(response).to.equal('');
646
+ let failure;
647
+ try { await model.message(); } catch (error) { failure = error; }
648
+ expect(failure).to.include({ statusCode: 502 });
649
+ expect(failure.message).to.include('no text or tool calls');
650
+ expect(model.messages).to.have.length(1);
651
+ expect(model.messages[0].role).to.equal('user');
649
652
  });
650
653
 
651
654
  it('should handle multiple addText before first message()', async () => {
package/test/json.test.js CHANGED
@@ -17,6 +17,34 @@ describe('JSON Schema and Structured Output Tests', () => {
17
17
  sinon.restore();
18
18
  });
19
19
 
20
+ describe('JSON response formatting', () => {
21
+ for (const suffix of ['', '```', '\n```\n']) {
22
+ it(`preserves embedded Markdown with trailing delimiter ${JSON.stringify(suffix)}`, async () => {
23
+ const value = { text: 'Use ```text\ncontent\n``` here.' };
24
+ const message = JSON.stringify(value) + suffix;
25
+ const mix = ModelMix.new();
26
+ const result = { message };
27
+ sinon.stub(mix, 'execute').resolves(result);
28
+ expect(await mix.json()).to.deep.equal(value);
29
+ expect(result.message).to.equal(message);
30
+ });
31
+ }
32
+
33
+ for (const message of ['{"ok":true} explanation```', '{"ok":true}{"other":true}```', '{"ok"":true}```']) {
34
+ it(`rejects invalid content even with a closing delimiter: ${message}`, async () => {
35
+ const mix = ModelMix.new();
36
+ sinon.stub(mix, 'execute').resolves({ message });
37
+ let failure;
38
+ try {
39
+ await mix.json();
40
+ } catch (error) {
41
+ failure = error;
42
+ }
43
+ expect(failure).to.be.instanceOf(SyntaxError);
44
+ });
45
+ }
46
+ });
47
+
20
48
  describe('JSON Schema Generation', () => {
21
49
  it('should generate schema for simple object', () => {
22
50
  const example = {
@@ -490,4 +518,4 @@ describe('JSON Schema and Structured Output Tests', () => {
490
518
  expect(result[2]).to.have.property('name', 'Spain');
491
519
  });
492
520
  });
493
- });
521
+ });
@@ -8,6 +8,7 @@ describe('public module boundary', () => {
8
8
  'MixAnthropic',
9
9
  'MixCerebras',
10
10
  'MixCustom',
11
+ 'MixDeepSeek',
11
12
  'MixFireworks',
12
13
  'MixGoogle',
13
14
  'MixGrok',
@@ -56,6 +57,7 @@ describe('public module boundary', () => {
56
57
  ['Gemini', config => new api.MixGoogle({ config })],
57
58
  ['MiniMax', config => new api.MixMiniMax({ config })],
58
59
  ['MiMo', config => new api.MixMiMo({ config })],
60
+ ['DeepSeek', config => new api.MixDeepSeek({ config })],
59
61
  ['Perplexity', config => new api.MixPerplexity({ config })],
60
62
  ['Grok', config => new api.MixGrok({ config })],
61
63
  ['Lambda', config => new api.MixLambda({ config })],
@@ -1,5 +1,5 @@
1
1
  import { expect } from 'chai';
2
- import { ModelMix, MixAnthropic, MixCustom, MixGoogle, MixMiMo, MixOpenAI, MixOpenAIResponses, MixOpenRouter } from '../index.js';
2
+ import { ModelMix, MixAnthropic, MixCustom, MixGoogle, MixGrok, MixMiMo, MixOpenAI, MixOpenAIResponses, MixOpenRouter } from '../index.js';
3
3
  import { createRequire } from 'module';
4
4
 
5
5
  const require = createRequire(import.meta.url);
@@ -7,6 +7,78 @@ const nock = require('nock');
7
7
 
8
8
  describe('Token Usage Tracking', () => {
9
9
 
10
+ it('adds native Grok reasoning to its exclusive completion count', () => {
11
+ const tokens = MixGrok.extractTokens({ usage: {
12
+ prompt_tokens: 656, completion_tokens: 2, total_tokens: 824,
13
+ prompt_tokens_details: { cached_tokens: 512 },
14
+ completion_tokens_details: { reasoning_tokens: 166 }
15
+ } });
16
+ expect(tokens).to.include({ input: 656, output: 2, thinking: 166, total: 824 });
17
+ expect(ModelMix.calculateCostBreakdown('grok-4.6', tokens).total).to.equal(0.001552);
18
+ });
19
+
20
+ it('combines Anthropic stream input usage with the final thinking breakdown', async () => {
21
+ const { Readable } = require('node:stream');
22
+ const chunks = [
23
+ { type: 'message_start', message: { usage: { input_tokens: 25, output_tokens: 1, cache_read_input_tokens: 10 } } },
24
+ { type: 'message_delta', usage: { output_tokens: 348, output_tokens_details: { thinking_tokens: 312 } } },
25
+ { type: 'message_stop' }
26
+ ];
27
+ const result = await new MixAnthropic().processStream({ data: Readable.from(chunks.map(chunk => `data: ${JSON.stringify(chunk)}\n\n`)) });
28
+ expect(result.tokens).to.include({ input: 35, output: 36, thinking: 312, total: 383, cached: 10 });
29
+ });
30
+
31
+ it('preserves Gemini stream thinking usage from the final metadata', async () => {
32
+ const { Readable } = require('node:stream');
33
+ const data = { usageMetadata: { promptTokenCount: 22, candidatesTokenCount: 5, thoughtsTokenCount: 99, totalTokenCount: 126 } };
34
+ const result = await new MixGoogle().processStream({ data: Readable.from([`data: ${JSON.stringify(data)}\n\n`]) });
35
+ expect(result.tokens).to.include({ input: 22, output: 5, thinking: 99, total: 126 });
36
+ });
37
+
38
+ for (const [name, extract, usage] of [
39
+ ['Chat Completions', MixCustom.extractTokens, { prompt_tokens: 615, completion_tokens: 5930, completion_tokens_details: { reasoning_tokens: 5900 } }],
40
+ ['Responses', MixOpenAIResponses.extractResponsesTokens, { input_tokens: 615, output_tokens: 5930, output_tokens_details: { reasoning_tokens: 5900 } }],
41
+ ['Anthropic', MixAnthropic.extractTokens, { input_tokens: 615, output_tokens: 5930, output_tokens_details: { thinking_tokens: 5900 } }]
42
+ ]) {
43
+ it(`separates ${name} reasoning without billing it twice`, () => {
44
+ const tokens = extract({ usage });
45
+ expect(tokens).to.include({ input: 615, output: 30, thinking: 5900, total: 6545 });
46
+ expect(ModelMix.calculateCostBreakdown('deepseek/deepseek-v4.1-flash', tokens).total).to.equal(0.00365025);
47
+ });
48
+ }
49
+
50
+ it('keeps streamed OpenRouter usage and actual cost through usage-only and trailing chunks', async () => {
51
+ const { Readable } = require('node:stream');
52
+ const provider = new MixOpenRouter();
53
+ const chunks = [
54
+ { choices: [{ delta: { reasoning: 'thinking' }, finish_reason: null }] },
55
+ { choices: [{ delta: { content: 'ok' }, finish_reason: 'stop' }] },
56
+ { choices: [], usage: { prompt_tokens: 10, completion_tokens: 12, completion_tokens_details: { reasoning_tokens: 10 }, cost: 0.123 } },
57
+ { choices: [] }
58
+ ];
59
+ provider.create = async () => provider.processStream({ data: Readable.from(chunks.map(chunk => `data: ${JSON.stringify(chunk)}\n\n`)) });
60
+ const model = ModelMix.new().attach('unlisted-model', provider).addText('test');
61
+ const deltas = [];
62
+ const result = await model.stream(({ delta }) => deltas.push(delta));
63
+ expect(deltas.join('')).to.equal('ok');
64
+ expect(result.tokens).to.include({ input: 10, output: 2, thinking: 10, total: 22, cost: 0.123 });
65
+ });
66
+
67
+ for (const cost of [0, 0.0073005]) {
68
+ it(`uses the reported OpenRouter cost ${cost} instead of catalog pricing`, async () => {
69
+ const provider = new MixOpenRouter();
70
+ provider.create = async () => provider.processResponse({ data: {
71
+ choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }],
72
+ usage: { prompt_tokens: 615, completion_tokens: 5930, completion_tokens_details: { reasoning_tokens: 5900 }, cost }
73
+ } });
74
+ const model = ModelMix.new().attach('deepseek/deepseek-v4.1-flash', provider).addText('test');
75
+ const result = await model.raw();
76
+ expect(result.tokens.cost).to.equal(cost);
77
+ expect(result.tokens).to.include({ output: 30, thinking: 5900 });
78
+ expect(model.lastRaw.tokens.cost).to.equal(cost);
79
+ });
80
+ }
81
+
10
82
  // Ensure nock doesn't interfere with live requests via MockHttpSocket
11
83
  before(function() {
12
84
  nock.cleanAll();
@@ -691,7 +763,7 @@ describe('Token Usage Tracking', () => {
691
763
 
692
764
  expect(result.tokens.input).to.be.greaterThan(0);
693
765
  expect(result.tokens.output).to.be.greaterThan(0);
694
- expect(result.tokens.total).to.equal(result.tokens.input + result.tokens.output);
766
+ expect(result.tokens.total).to.equal(result.tokens.input + result.tokens.output + result.tokens.thinking);
695
767
  });
696
768
 
697
769
  it('should track tokens in Google Gemini response', async function () {
@@ -735,7 +807,7 @@ describe('Token Usage Tracking', () => {
735
807
  expect(result2.tokens.output).to.be.greaterThan(0);
736
808
 
737
809
  // Verify both results have valid token counts
738
- expect(result1.tokens.total).to.equal(result1.tokens.input + result1.tokens.output);
810
+ expect(result1.tokens.total).to.equal(result1.tokens.input + result1.tokens.output + result1.tokens.thinking);
739
811
  expect(result2.tokens.total).to.be.greaterThan(0);
740
812
  });
741
813