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.
@@ -146,6 +146,10 @@ function createBaseProviders({ ModelMix }) {
146
146
  let raw = [];
147
147
  let message = '';
148
148
  let buffer = '';
149
+ let completed = false;
150
+ let chatCompletion = false;
151
+ let usage = {};
152
+ let usageMetadata;
149
153
 
150
154
  response.data.on('data', chunk => {
151
155
  buffer += chunk.toString();
@@ -156,39 +160,75 @@ function createBaseProviders({ ModelMix }) {
156
160
  buffer = buffer.slice(boundary + 1);
157
161
 
158
162
  const firstBraceIndex = dataStr.indexOf('{');
159
- if (dataStr === '[DONE]' || firstBraceIndex === -1) continue;
163
+ if (dataStr === 'data: [DONE]' || dataStr === '[DONE]') {
164
+ completed = true;
165
+ continue;
166
+ }
167
+ if (firstBraceIndex === -1) continue;
160
168
 
161
169
  const jsonStr = dataStr.slice(firstBraceIndex);
162
170
  try {
163
171
  const data = JSON.parse(jsonStr);
172
+ MixCustom.assertResponse(data);
173
+ chatCompletion ||= Array.isArray(data.choices);
174
+ completed ||= Boolean(data.choices?.[0]?.finish_reason);
175
+ raw.push(data);
176
+ usage = { ...usage, ...data.message?.usage, ...data.usage };
177
+ if (data.usageMetadata) usageMetadata = data.usageMetadata;
178
+ const delta = this.extractDelta(data);
179
+ message += delta;
164
180
  if (this.streamCallback) {
165
- const delta = this.extractDelta(data);
166
- message += delta;
167
181
  this.streamCallback({ response: data, message, delta });
168
- raw.push(data);
169
182
  }
170
183
  } catch (error) {
171
- console.error('Error parsing JSON:', error);
184
+ reject(error);
185
+ response.data.destroy();
186
+ return;
172
187
  }
173
188
  }
174
189
  });
175
190
 
176
- response.data.on('end', () => resolve({
177
- response: raw,
178
- message: message.trim(),
179
- toolCalls: [],
180
- think: null,
181
- tokens: raw.length > 0 ? MixCustom.extractTokens(raw[raw.length - 1]) : { input: 0, output: 0, total: 0, cached: 0 }
182
- }));
191
+ response.data.on('end', () => {
192
+ if (chatCompletion && !completed) {
193
+ reject(MixCustom.responseError(raw.at(-1), 'Provider stream ended without completing.'));
194
+ return;
195
+ }
196
+ resolve({
197
+ response: raw,
198
+ message: message.trim(),
199
+ toolCalls: [],
200
+ think: null,
201
+ tokens: this.constructor.extractTokens({ usage, usageMetadata })
202
+ });
203
+ });
183
204
  response.data.on('error', reject);
184
205
  });
185
206
  }
186
207
 
187
208
  extractDelta(data) {
188
- return data.choices[0].delta.content;
209
+ return data.choices?.[0]?.delta?.content || '';
210
+ }
211
+
212
+ static responseError(data, message, statusCode = 502) {
213
+ const error = new Error(message);
214
+ error.statusCode = statusCode;
215
+ error.details = data;
216
+ return error;
217
+ }
218
+
219
+ static assertResponse(data) {
220
+ const failure = data?.error || data?.choices?.find(choice => choice.error)?.error;
221
+ if (failure || data?.choices?.some(choice => choice.finish_reason === 'error') || data?.status === 'failed') {
222
+ throw MixCustom.responseError(
223
+ data,
224
+ failure?.message || 'Provider failed to complete the response.',
225
+ Number.isInteger(failure?.code) && failure.code >= 400 && failure.code <= 599 ? failure.code : 502
226
+ );
227
+ }
189
228
  }
190
229
 
191
230
  static extractMessage(data) {
231
+ MixCustom.assertResponse(data);
192
232
  const choice = data?.choices?.[0] || {};
193
233
  const messageObj = choice.message || {};
194
234
  const finishReason = choice.finish_reason;
@@ -254,9 +294,12 @@ function createBaseProviders({ ModelMix }) {
254
294
  static extractTokens(data) {
255
295
  // OpenAI/Groq/Together/Lambda/Cerebras/Fireworks format
256
296
  if (data.usage) {
297
+ const output = data.usage.completion_tokens || 0;
298
+ const thinking = data.usage.completion_tokens_details?.reasoning_tokens || 0;
257
299
  return ModelMix.normalizeTokenUsage({
258
300
  input: data.usage.prompt_tokens || 0,
259
- output: data.usage.completion_tokens || 0,
301
+ output: output - thinking,
302
+ thinking,
260
303
  total: data.usage.total_tokens,
261
304
  cached: ModelMix.extractCacheTokens(data.usage),
262
305
  cacheWrite: ModelMix.extractCacheWriteTokens(data.usage)
@@ -266,11 +309,16 @@ function createBaseProviders({ ModelMix }) {
266
309
  }
267
310
 
268
311
  processResponse(response) {
312
+ const message = MixCustom.extractMessage(response.data);
313
+ const toolCalls = MixCustom.extractToolCalls(response.data);
314
+ if (!message && toolCalls.length === 0 && response.data?.choices?.[0]?.finish_reason !== 'length') {
315
+ throw MixCustom.responseError(response.data, 'Provider returned no text or tool calls without completing a usable response.');
316
+ }
269
317
  return {
270
- message: MixCustom.extractMessage(response.data),
318
+ message,
271
319
  think: MixCustom.extractThink(response.data),
272
- toolCalls: MixCustom.extractToolCalls(response.data),
273
- tokens: MixCustom.extractTokens(response.data),
320
+ toolCalls,
321
+ tokens: this.constructor.extractTokens(response.data),
274
322
  response: response.data
275
323
  }
276
324
  }
@@ -61,6 +61,12 @@ function createGoogleProviders({ ModelMix, MixCustom }) {
61
61
  }
62
62
  }
63
63
 
64
+ if (typeof message.content === 'string') {
65
+ return {
66
+ role: message.role === 'assistant' ? 'model' : 'user',
67
+ parts: [{ text: message.content }]
68
+ };
69
+ }
64
70
  if (!Array.isArray(message.content)) return message;
65
71
  const role = (message.role === 'assistant' || message.role === 'tool') ? 'model' : 'user'
66
72
 
@@ -164,7 +170,10 @@ function createGoogleProviders({ ModelMix, MixCustom }) {
164
170
  options.tools.some(t => t.functionDeclarations && t.functionDeclarations.length > 0);
165
171
 
166
172
  if (!hasTools) {
167
- generationConfig.responseMimeType = "text/plain";
173
+ generationConfig.responseMimeType = options.response_format?.type === 'json_object'
174
+ || options.response_format?.type === 'json_schema'
175
+ ? 'application/json'
176
+ : 'text/plain';
168
177
  }
169
178
 
170
179
  const payload = {
@@ -230,7 +239,9 @@ function createGoogleProviders({ ModelMix, MixCustom }) {
230
239
  }
231
240
 
232
241
  static extractMessage(data) {
233
- return data.candidates?.[0]?.content?.parts?.[0]?.text;
242
+ const parts = data.candidates?.[0]?.content?.parts;
243
+ const textParts = parts?.filter(part => !part.thought && typeof part.text === 'string');
244
+ return textParts?.length ? textParts.map(part => part.text).join('') : undefined;
234
245
  }
235
246
 
236
247
  static extractTokens(data) {
@@ -5,6 +5,29 @@ const {
5
5
  const { requireProviderApiKey } = require('../provider-api-key');
6
6
 
7
7
  function createCompatibleProviders({ MixCustom, MixOpenAI }) {
8
+ class MixDeepSeek extends MixOpenAI {
9
+ getDefaultConfig(customConfig) {
10
+ const apiKey = requireProviderApiKey(customConfig, 'DEEPSEEK_API_KEY', 'DeepSeek');
11
+
12
+ return MixCustom.prototype.getDefaultConfig.call(this, {
13
+ url: 'https://api.deepseek.com/chat/completions',
14
+ apiKey,
15
+ ...customConfig
16
+ });
17
+ }
18
+
19
+ extractDelta(data) {
20
+ return data?.choices?.[0]?.delta?.content || '';
21
+ }
22
+
23
+ processResponse(response) {
24
+ return {
25
+ ...super.processResponse(response),
26
+ assistantMessage: response.data?.choices?.[0]?.message
27
+ };
28
+ }
29
+ }
30
+
8
31
  class MixMiniMax extends MixOpenAI {
9
32
  getDefaultConfig(customConfig) {
10
33
  const apiKey = requireProviderApiKey(customConfig, 'MINIMAX_API_KEY', 'MiniMax');
@@ -124,6 +147,18 @@ function createCompatibleProviders({ MixCustom, MixOpenAI }) {
124
147
  }
125
148
 
126
149
  class MixGrok extends MixOpenAI {
150
+ static extractTokens(data) {
151
+ if (!data.usage) return MixCustom.extractTokens(data);
152
+ return MixCustom.extractTokens({
153
+ ...data,
154
+ usage: {
155
+ ...data.usage,
156
+ completion_tokens: (data.usage.completion_tokens || 0)
157
+ + (data.usage.completion_tokens_details?.reasoning_tokens || 0)
158
+ }
159
+ });
160
+ }
161
+
127
162
  getDefaultConfig(customConfig) {
128
163
  const apiKey = requireProviderApiKey(customConfig, 'XAI_API_KEY', 'Grok');
129
164
 
@@ -292,6 +327,7 @@ function createCompatibleProviders({ MixCustom, MixOpenAI }) {
292
327
  }
293
328
 
294
329
  return {
330
+ MixDeepSeek,
295
331
  MixMiniMax,
296
332
  MixMiMo,
297
333
  MixPerplexity,
@@ -136,6 +136,7 @@ function createOpenAIProviders({
136
136
  }
137
137
 
138
138
  static processResponsesResponse(response) {
139
+ MixCustom.assertResponse(response.data);
139
140
  const message = MixOpenAIResponses.extractResponsesMessage(response.data);
140
141
  return {
141
142
  message,
@@ -148,9 +149,12 @@ function createOpenAIProviders({
148
149
 
149
150
  static extractResponsesTokens(data) {
150
151
  if (data.usage) {
152
+ const output = data.usage.output_tokens || 0;
153
+ const thinking = data.usage.output_tokens_details?.reasoning_tokens || 0;
151
154
  return ModelMix.normalizeTokenUsage({
152
155
  input: data.usage.input_tokens || 0,
153
- output: data.usage.output_tokens || 0,
156
+ output: output - thinking,
157
+ thinking,
154
158
  total: data.usage.total_tokens,
155
159
  cached: ModelMix.extractCacheTokens(data.usage),
156
160
  cacheWrite: ModelMix.extractCacheWriteTokens(data.usage)
@@ -96,6 +96,10 @@ const MODEL_PRICING = {
96
96
  'deepseek-ai/DeepSeek-V4-Flash': { input: 0.14, output: 0.28 },
97
97
  'deepseek-ai/DeepSeek-V4-Pro': { input: 2.10, output: 4.40 },
98
98
  'deepseek/deepseek-v4-flash': { input: 0.09, output: 0.18 },
99
+ 'deepseek/deepseek-v4.1-flash': { input: 0.15, cachedInput: 0.015, output: 0.60 },
100
+ 'accounts/fireworks/models/deepseek-v4p1-flash': { input: 0.22, cachedInput: 0.007, output: 0.66 },
101
+ 'deepseek-flash': { input: 0.30, cachedInput: 0.006, output: 1.20 },
102
+ 'deepseek/deepseek-v4-pro-0813': { input: 0.5808, cachedInput: 0.05808, output: 1.7424 },
99
103
  'accounts/fireworks/models/glm-4p7': { input: 0.55, output: 2.19 },
100
104
  'zai-org/GLM-5.2': { input: 1.40, cachedInput: 0.26, output: 4.40 },
101
105
  'accounts/fireworks/models/glm-5p2': { input: 1.40, cachedInput: 0.14, output: 4.40 },
@@ -322,6 +326,7 @@ function getModelPricing(modelKey) {
322
326
  function extractCacheTokens(usage = {}) {
323
327
  return usage.input_tokens_details?.cached_tokens
324
328
  ?? usage.prompt_tokens_details?.cached_tokens
329
+ ?? usage.prompt_cache_hit_tokens
325
330
  ?? usage.cache_read_input_tokens
326
331
  ?? usage.cachedContentTokenCount
327
332
  ?? usage.cached_content_token_count
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelmix",
3
- "version": "5.1.20",
3
+ "version": "5.2.0",
4
4
  "description": "🧬 Reliable interface with automatic fallback for AI LLMs.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -77,7 +77,8 @@
77
77
  "test:live.mcp": "mocha test/live.mcp.js --timeout 60000 --require test/setup.js",
78
78
  "test:tokens": "mocha test/tokens.test.js --timeout 10000 --require test/setup.js",
79
79
  "test:plugins": "mocha test/plugins.test.js --timeout 10000 --require test/setup.js",
80
+ "test:benchmark": "mocha plugins/benchmark/test/**/*.test.js --timeout 10000 --require test/setup.js",
80
81
  "test:rlm": "mocha plugins/rlm/test/**/*.test.js --timeout 10000 --require test/setup.js",
81
- "test:offline": "mocha test/abort.test.js test/json.test.js test/fallback.test.js test/templates.test.js test/images.test.js test/bottleneck.test.js test/tokens.test.js test/history.test.js test/anthropic.test.js test/effort.test.js test/grok.test.js test/moderation.test.js test/plugins.test.js plugins/rlm/test/**/*.test.js --timeout 10000 --require test/setup.js"
82
+ "test:offline": "mocha test/abort.test.js test/json.test.js test/fallback.test.js test/templates.test.js test/images.test.js test/bottleneck.test.js test/tokens.test.js test/history.test.js test/anthropic.test.js test/effort.test.js test/grok.test.js test/google.test.js test/moderation.test.js test/plugins.test.js plugins/benchmark/test/**/*.test.js plugins/rlm/test/**/*.test.js --timeout 10000 --require test/setup.js"
82
83
  }
83
84
  }
@@ -0,0 +1,112 @@
1
+ import type { ChatMessage, ModelMixMixFlags, ModelMixPlugin, TokenUsage } from '../..';
2
+
3
+ export interface BenchmarkOptions {
4
+ criteriaModel: string;
5
+ models: string[];
6
+ mix?: ModelMixMixFlags;
7
+ }
8
+
9
+ export interface BenchmarkModel {
10
+ id: string;
11
+ shortcut: string;
12
+ effort: number | null;
13
+ canonicalModel: string;
14
+ }
15
+
16
+ export interface BenchmarkCriterion {
17
+ id: string;
18
+ description: string;
19
+ }
20
+
21
+ export interface BenchmarkScore {
22
+ criterionId: string;
23
+ score: number;
24
+ justification: string;
25
+ }
26
+
27
+ export interface BenchmarkCallMetrics {
28
+ elapsedMs: number;
29
+ tokens: Partial<TokenUsage> | null;
30
+ cost: number | null;
31
+ }
32
+
33
+ export interface BenchmarkEvaluation {
34
+ judge: BenchmarkModel;
35
+ scores: BenchmarkScore[];
36
+ metrics: BenchmarkCallMetrics;
37
+ }
38
+
39
+ export interface BenchmarkCriterionAverage {
40
+ criterionId: string;
41
+ score: number | null;
42
+ }
43
+
44
+ export interface BenchmarkResult {
45
+ id: string;
46
+ shortcut: string;
47
+ effort: number | null;
48
+ canonicalModel: string;
49
+ response: string | null;
50
+ responseMetrics: BenchmarkCallMetrics | null;
51
+ evaluations: BenchmarkEvaluation[];
52
+ averages: BenchmarkCriterionAverage[] | null;
53
+ score: number | null;
54
+ evaluationCount: {
55
+ expected: number;
56
+ valid: number;
57
+ };
58
+ }
59
+
60
+ export interface BenchmarkErrorDetails {
61
+ name: string;
62
+ message: string;
63
+ code?: unknown;
64
+ statusCode?: unknown;
65
+ details?: unknown;
66
+ }
67
+
68
+ export interface BenchmarkError {
69
+ stage: 'response' | 'evaluation';
70
+ participant: string;
71
+ judge?: string;
72
+ error: BenchmarkErrorDetails;
73
+ metrics: BenchmarkCallMetrics;
74
+ }
75
+
76
+ export interface BenchmarkTokenTotals {
77
+ input?: number;
78
+ output?: number;
79
+ thinking?: number;
80
+ total?: number;
81
+ cached?: number;
82
+ cacheWrite?: number;
83
+ cacheWrite5m?: number;
84
+ cacheWrite1h?: number;
85
+ uncachedInput?: number;
86
+ }
87
+
88
+ export interface BenchmarkReport {
89
+ task: {
90
+ system: string;
91
+ messages: ChatMessage[];
92
+ };
93
+ criteria: {
94
+ model: BenchmarkModel;
95
+ items: BenchmarkCriterion[];
96
+ metrics: BenchmarkCallMetrics;
97
+ };
98
+ results: BenchmarkResult[];
99
+ errors: BenchmarkError[];
100
+ metrics: {
101
+ elapsedMs: number;
102
+ tokens: BenchmarkTokenTotals | null;
103
+ cost: number | null;
104
+ calls: {
105
+ attempted: number;
106
+ withTokens: number;
107
+ withCost: number;
108
+ };
109
+ };
110
+ }
111
+
112
+ export declare function benchmark(options: BenchmarkOptions): ModelMixPlugin;