modelmix 5.1.1 → 5.1.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/index.js CHANGED
@@ -7,30 +7,52 @@ const { inspect } = require('util');
7
7
  const log = require('lemonlog')('ModelMix');
8
8
  const Bottleneck = require('bottleneck');
9
9
  const path = require('path');
10
- const WebSocket = require('ws');
11
10
  const generateJsonSchema = require('./schema');
12
11
  const { Client } = require("@modelcontextprotocol/sdk/client/index.js");
13
12
  const { StdioClientTransport } = require("@modelcontextprotocol/sdk/client/stdio.js");
14
13
  const { MCPToolsManager } = require('./mcp-tools');
14
+ const { fetchBinaryResponse } = require('./http-client');
15
+ const { isPlainObject } = require('./lib/object-utils');
16
+ const { normalizeContentCache } = require('./lib/content-cache');
17
+ const tokenUsage = require('./lib/token-usage');
18
+ const { parseChainModels } = require('./lib/model-chain');
15
19
  const {
16
- stripContentTypeHeader,
17
- createMultipartFormData,
18
- buildRequestBodyAndHeaders
19
- } = require('./multipart');
20
- const {
21
- fetchJsonResponse,
22
- fetchBinaryResponse,
23
- fetchStreamResponse
24
- } = require('./http-client');
20
+ validateTemplateData,
21
+ validateTemplateDataKey,
22
+ preprocessChoiceDirectives,
23
+ createTemplateRenderContext
24
+ } = require('./lib/template-engine');
25
25
  const {
26
26
  normalizeEffort,
27
27
  applyUnifiedEffort,
28
28
  resolveProviderFamily,
29
- resolveGrok420ModelKey,
30
- GROK420_REASONING,
31
- GROK420_NON_REASONING
29
+ resolveGrok420ModelKey
32
30
  } = require('./effort');
33
31
 
32
+ let MixCustom;
33
+ let MixOpenAI;
34
+ let MixModeration;
35
+ let MixOpenAIResponses;
36
+ let MixOpenAIModeration;
37
+ let MixOpenAIWebSocket;
38
+ let MixOpenRouter;
39
+ let MixKimi;
40
+ let MixAnthropic;
41
+ let MixMiniMax;
42
+ let MixMiMo;
43
+ let MixPerplexity;
44
+ let MixOllama;
45
+ let MixGrok;
46
+ let MixLambda;
47
+ let MixLMStudio;
48
+ let MixGroq;
49
+ let MixTogether;
50
+ let MixCerebras;
51
+ let MixFireworks;
52
+ let MixNVIDIA;
53
+ let MixGoogle;
54
+ let ModerationMix;
55
+
34
56
  const DEFAULT_RETRYABLE_STATUS_CODES = [408, 425, 429, 500, 502, 503, 504, 529];
35
57
 
36
58
  function getErrorStatusCode(error) {
@@ -41,12 +63,6 @@ function sleep(ms) {
41
63
  return new Promise(resolve => setTimeout(resolve, ms));
42
64
  }
43
65
 
44
- function isPlainObject(value) {
45
- if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
46
- const prototype = Object.getPrototypeOf(value);
47
- return prototype === Object.prototype || prototype === null;
48
- }
49
-
50
66
  function clonePluginValue(value, seen = new WeakMap()) {
51
67
  if (value === null || typeof value !== 'object') return value;
52
68
  if (Buffer.isBuffer(value)) return Buffer.from(value);
@@ -75,381 +91,7 @@ function validatePluginResult(result, pluginName) {
75
91
  return result;
76
92
  }
77
93
 
78
- function normalizeContentCache(cache) {
79
- if (cache !== undefined) {
80
- if (!isPlainObject(cache) || cache.breakpoint !== true) {
81
- throw new TypeError('cache must be { breakpoint: true }.');
82
- }
83
- return { breakpoint: true };
84
- }
85
- return undefined;
86
- }
87
-
88
- function stripContentCacheMetadata(content) {
89
- if (!content || typeof content !== 'object') return content;
90
- const sanitized = { ...content };
91
- delete sanitized.cache;
92
- delete sanitized.cache_control;
93
- delete sanitized.prompt_cache_breakpoint;
94
- return sanitized;
95
- }
96
-
97
- function hasNeutralCacheBreakpoint(messages = []) {
98
- return messages.some(message => Array.isArray(message?.content)
99
- && message.content.some(block => block?.cache?.breakpoint === true));
100
- }
101
-
102
- function validateTemplateData(value) {
103
- if (!isPlainObject(value)) {
104
- throw new TypeError('Template data must be a plain non-null object.');
105
- }
106
- if (Object.prototype.hasOwnProperty.call(value, '$mix')) {
107
- throw new TypeError('Template data key "$mix" is reserved.');
108
- }
109
- }
110
-
111
- function validateTemplateDataKey(key) {
112
- if (typeof key !== 'string' || key.length === 0) {
113
- throw new TypeError('Template data key must be a non-empty string.');
114
- }
115
- if (key === '$mix') {
116
- throw new TypeError('Template data key "$mix" is reserved.');
117
- }
118
- }
119
-
120
- function templateLocation({ filename, label }, lineNumber) {
121
- return `${filename || label} at line ${lineNumber}`;
122
- }
123
-
124
- function preprocessChoiceDirectives(source, { filename = null, label = 'template' } = {}) {
125
- const parts = source.split(/(\r\n|\n|\r)/);
126
- const blocks = [];
127
-
128
- for (let index = 0; index < parts.length; index += 2) {
129
- const line = parts[index];
130
- const trimmed = line.trim();
131
- const lineNumber = (index / 2) + 1;
132
- const location = templateLocation({ filename, label }, lineNumber);
133
- const newline = parts[index + 1] || '';
134
-
135
- if (/^<%\s*choice\s*%>$/.test(trimmed)) {
136
- const parent = blocks[blocks.length - 1];
137
- if (parent && parent.optionCount === 0) {
138
- throw new Error(`A nested choice must be inside an option (${location}).`);
139
- }
140
- blocks.push({ lineNumber, optionCount: 0, weighted: null });
141
- parts[index] = '<% $mix.choice(option => { -%>';
142
- continue;
143
- }
144
-
145
- const optionMatch = trimmed.match(/^<%\s*option(?:\s+(.+?))?\s*%>$/);
146
- if (optionMatch) {
147
- const block = blocks[blocks.length - 1];
148
- if (!block) {
149
- throw new Error(`Option directive must be inside a choice (${location}).`);
150
- }
151
-
152
- const weightText = optionMatch[1];
153
- const weighted = weightText !== undefined;
154
- if (block.weighted !== null && block.weighted !== weighted) {
155
- throw new Error(`Choice options must either all have weights or all omit them (${location}).`);
156
- }
157
-
158
- let argument = '';
159
- if (weighted) {
160
- const weight = Number(weightText);
161
- if (!Number.isFinite(weight) || weight <= 0) {
162
- throw new Error(`Choice weight must be a positive finite number (${location}).`);
163
- }
164
- argument = `${weight}, `;
165
- }
166
-
167
- block.weighted = weighted;
168
- parts[index] = `<% ${block.optionCount > 0 ? '}); ' : ''}option(${argument}() => { -%>`;
169
- block.optionCount += 1;
170
- continue;
171
- }
172
-
173
- if (/^<%\s*\/choice\s*%>$/.test(trimmed)) {
174
- const block = blocks.pop();
175
- if (!block) {
176
- throw new Error(`Closing choice directive has no matching opening directive (${location}).`);
177
- }
178
- if (block.optionCount === 0) {
179
- throw new Error(`Choice must contain at least one option (${location}).`);
180
- }
181
- parts[index] = '<% }); }); -%>';
182
- continue;
183
- }
184
-
185
- if (/^<%\s*(?:choice|option|\/choice)(?:\s|%>)/.test(trimmed)) {
186
- throw new Error(`Invalid choice directive (${location}).`);
187
- }
188
-
189
- const block = blocks[blocks.length - 1];
190
- if (block && block.optionCount === 0) {
191
- if (trimmed) {
192
- throw new Error(`Choice content must be inside an option (${location}).`);
193
- }
194
- parts[index] = '<%# -%>';
195
- }
196
-
197
- if (newline) parts[index + 1] = newline;
198
- }
199
-
200
- if (blocks.length > 0) {
201
- const block = blocks[blocks.length - 1];
202
- throw new Error(`Unclosed choice directive (${templateLocation({ filename, label }, block.lineNumber)}).`);
203
- }
204
-
205
- return parts.join('');
206
- }
207
-
208
- function createTemplateRenderContext(random = Math.random) {
209
- const choice = defineOptions => {
210
- if (typeof defineOptions !== 'function') {
211
- throw new TypeError('$mix.choice expects an option definition callback.');
212
- }
213
-
214
- const options = [];
215
- let weighted = null;
216
- const option = (weightOrRender, renderOption) => {
217
- const hasWeight = renderOption !== undefined;
218
- const weight = hasWeight ? weightOrRender : 1;
219
- const render = hasWeight ? renderOption : weightOrRender;
220
-
221
- if (weighted !== null && weighted !== hasWeight) {
222
- throw new TypeError('$mix.choice options cannot mix weighted and unweighted forms.');
223
- }
224
- if (!Number.isFinite(weight) || weight <= 0) {
225
- throw new TypeError('$mix.choice weights must be positive finite numbers.');
226
- }
227
- if (typeof render !== 'function') {
228
- throw new TypeError('$mix.choice options require a render callback.');
229
- }
230
-
231
- weighted = hasWeight;
232
- options.push({ weight, render });
233
- };
234
-
235
- defineOptions(option);
236
- if (options.length === 0) {
237
- throw new Error('$mix.choice requires at least one option.');
238
- }
239
-
240
- const totalWeight = options.reduce((sum, current) => sum + current.weight, 0);
241
- if (!Number.isFinite(totalWeight)) {
242
- throw new TypeError('$mix.choice total weight must be finite.');
243
- }
244
-
245
- let target = random() * totalWeight;
246
- for (const current of options) {
247
- target -= current.weight;
248
- if (target < 0) return current.render();
249
- }
250
- return options[options.length - 1].render();
251
- };
252
-
253
- return {
254
- helpers: Object.freeze({ choice }),
255
- renderedTemplateData: new Map(),
256
- renderedMessages: new Map(),
257
- renderedSystems: new Map()
258
- };
259
- }
260
-
261
- function configForDebug(config) {
262
- const safeConfig = { ...config };
263
- delete safeConfig.apiKey;
264
- delete safeConfig.debug;
265
- return safeConfig;
266
- }
267
-
268
- function redactSecret(value, secret, seen = new WeakSet()) {
269
- if (!secret) return value;
270
- if (typeof value === 'string') return value.split(secret).join('[REDACTED]');
271
- if (!value || typeof value !== 'object') return value;
272
- if (seen.has(value)) return '[Circular]';
273
-
274
- seen.add(value);
275
- if (Array.isArray(value)) {
276
- return value.map(item => redactSecret(item, secret, seen));
277
- }
278
- return Object.fromEntries(
279
- Object.entries(value).map(([key, item]) => [key, redactSecret(item, secret, seen)])
280
- );
281
- }
282
-
283
- // Pricing per 1M tokens in USD
284
- // Based on provider pricing pages linked in README
285
- const GPT56_LONG_CONTEXT_PRICING = Object.freeze({
286
- inputThreshold: 272_000,
287
- inputMultiplier: 2,
288
- outputMultiplier: 1.5
289
- });
290
-
291
- const GROK46_LONG_CONTEXT_PRICING = Object.freeze({
292
- inputThreshold: 200_000,
293
- inputMultiplier: 2,
294
- outputMultiplier: 2,
295
- inclusive: true
296
- });
297
-
298
- function usesLongContextRates(pricing, inputTokens) {
299
- const longContext = pricing.longContext;
300
- if (!longContext) return false;
301
- return longContext.inclusive
302
- ? inputTokens >= longContext.inputThreshold
303
- : inputTokens > longContext.inputThreshold;
304
- }
305
-
306
- const MODEL_PRICING = {
307
- // OpenAI
308
- 'gpt-realtime-mini': { input: 0.60, cachedInput: 0.06, output: 2.40 },
309
- 'gpt-realtime': { input: 4.00, cachedInput: 0.40, output: 16.00 },
310
- 'gpt-5.6-sol': { input: 5.00, cachedInput: 0.50, cacheWrite: 6.25, output: 30.00, longContext: GPT56_LONG_CONTEXT_PRICING },
311
- 'gpt-5.6-terra': { input: 2.00, cachedInput: 0.20, cacheWrite: 2.50, output: 12.00, longContext: GPT56_LONG_CONTEXT_PRICING },
312
- 'gpt-5.6-luna': { input: 0.20, cachedInput: 0.02, cacheWrite: 0.25, output: 1.20, longContext: GPT56_LONG_CONTEXT_PRICING },
313
- 'gpt-5.5-pro': { input: 30.00, output: 180.00 },
314
- 'gpt-5.5': { input: 5.00, cachedInput: 0.50, output: 30.00 },
315
- 'gpt-5.4': { input: 2.50, cachedInput: 0.25, output: 15.00 },
316
- 'gpt-5.4-pro': { input: 30.00, output: 180.00 },
317
- 'gpt-5.4-mini': { input: 0.75, cachedInput: 0.075, output: 4.50 },
318
- 'gpt-5.4-nano': { input: 0.20, cachedInput: 0.02, output: 1.25 },
319
- 'gpt-5.3-codex': { input: 1.75, cachedInput: 0.175, output: 14.00 },
320
- 'gpt-5.2': { input: 1.75, cachedInput: 0.175, output: 14.00 },
321
- 'gpt-5.2-chat-latest': { input: 1.75, cachedInput: 0.175, output: 14.00 },
322
- 'gpt-5.1': { input: 1.25, cachedInput: 0.125, output: 10.00 },
323
- 'gpt-5': { input: 1.25, cachedInput: 0.125, output: 10.00 },
324
- 'gpt-5-mini': { input: 0.25, cachedInput: 0.025, output: 2.00 },
325
- 'gpt-5-nano': { input: 0.05, cachedInput: 0.005, output: 0.40 },
326
- 'gpt-4.1': { input: 2.00, cachedInput: 0.50, output: 8.00 },
327
- 'gpt-4.1-mini': { input: 0.40, cachedInput: 0.10, output: 1.60 },
328
- 'gpt-4.1-nano': { input: 0.10, cachedInput: 0.025, output: 0.40 },
329
- // gptOss (Together/Groq/Cerebras/OpenRouter)
330
- 'openai/gpt-oss-120b': { input: 0.15, output: 0.60 },
331
- 'gpt-oss-120b': { input: 0.15, output: 0.60 },
332
- 'openai/gpt-oss-120b:free': { input: 0, output: 0 },
333
- // Anthropic
334
- 'claude-fable-5': { input: 10.00, cachedInput: 1.00, cacheWrite: 12.50, cacheWrite1h: 20.00, output: 50.00 },
335
- 'claude-opus-5': { input: 5.00, cachedInput: 0.50, cacheWrite: 6.25, cacheWrite1h: 10.00, output: 25.00 },
336
- 'claude-sonnet-5': { input: 3.00, cachedInput: 0.30, cacheWrite: 3.75, cacheWrite1h: 6.00, output: 15.00 },
337
- 'claude-opus-4-8': { input: 5.00, cachedInput: 0.50, cacheWrite: 6.25, cacheWrite1h: 10.00, output: 25.00 },
338
- 'claude-opus-4-7': { input: 5.00, cachedInput: 0.50, cacheWrite: 6.25, cacheWrite1h: 10.00, output: 25.00 },
339
- 'claude-opus-4-6': { input: 5.00, cachedInput: 0.50, cacheWrite: 6.25, cacheWrite1h: 10.00, output: 25.00 },
340
- 'claude-sonnet-4-6': { input: 3.00, cachedInput: 0.30, cacheWrite: 3.75, cacheWrite1h: 6.00, output: 15.00 },
341
- 'claude-sonnet-4-5-20250929': { input: 3.00, cachedInput: 0.30, cacheWrite: 3.75, cacheWrite1h: 6.00, output: 15.00 },
342
- 'claude-haiku-4-5-20251001': { input: 1.00, cachedInput: 0.10, cacheWrite: 1.25, cacheWrite1h: 2.00, output: 5.00 },
343
- // Google
344
- 'gemini-3.1-pro-preview': { input: 2.00, output: 12.00 },
345
- 'gemini-3-pro-preview': { input: 2.00, output: 12.00 },
346
- 'gemini-3-flash-preview': { input: 0.50, output: 3.00 },
347
- 'gemini-3.7-flash': { input: 0.75, cachedInput: 0.075, output: 3.75 },
348
- 'gemini-3.6-flash': { input: 0.75, cachedInput: 0.075, output: 3.75 },
349
- 'gemini-3.5-flash': { input: 0.75, output: 4.50 },
350
- 'gemini-3.5-flash-lite': { input: 0.30, output: 2.50 },
351
- 'gemini-2.5-pro': { input: 1.25, output: 10.00 },
352
- 'gemini-2.5-flash': { input: 0.30, output: 2.50 },
353
- 'gemini-3.1-flash-lite-preview': { input: 0.25, output: 1.50 },
354
- // Grok
355
- 'grok-4.6': { input: 2.00, cachedInput: 0.50, output: 6.00, longContext: GROK46_LONG_CONTEXT_PRICING },
356
- 'grok-4.5': { input: 2.00, output: 6.00 },
357
- 'grok-4.3': { input: 1.25, output: 2.50 },
358
- 'grok-4.20-multi-agent-0309': { input: 1.25, output: 2.50 },
359
- 'grok-4.20-0309': { input: 1.25, output: 2.50 },
360
- 'grok-4.20-0309-reasoning': { input: 1.25, output: 2.50 },
361
- 'grok-4.20-0309-non-reasoning': { input: 1.25, output: 2.50 },
362
- // Fireworks
363
- 'accounts/fireworks/models/deepseek-v4-flash': { input: 0.14, output: 0.28 },
364
- 'accounts/fireworks/models/deepseek-v4-pro': { input: 1.74, output: 3.48 },
365
- 'accounts/fireworks/models/deepseek-v4-pro-0813': { input: 1.32, cachedInput: 0.044, output: 3.96 },
366
- 'deepseek-ai/DeepSeek-V4-Flash': { input: 0.14, output: 0.28 },
367
- 'deepseek-ai/DeepSeek-V4-Pro': { input: 2.10, output: 4.40 },
368
- 'deepseek/deepseek-v4-flash': { input: 0.09, output: 0.18 },
369
- 'accounts/fireworks/models/glm-4p7': { input: 0.55, output: 2.19 },
370
- 'accounts/fireworks/models/glm-5p1': { input: 1.05, output: 3.50 },
371
- 'zai-org/GLM-5.2': { input: 1.40, output: 4.40 },
372
- 'accounts/fireworks/models/kimi-k2p5': { input: 0.50, output: 2.80 },
373
- 'qwen/qwen3.5-397b-a17b': { input: 0.385, output: 2.45 },
374
- 'accounts/fireworks/models/qwen3p6-plus': { input: 0.50, output: 3.00 },
375
- 'Qwen/Qwen3.6-Plus': { input: 0.50, output: 3.00 },
376
- 'accounts/fireworks/models/qwen3p7-plus': { input: 0.40, output: 1.60 },
377
- 'qwen/qwen3.7-plus': { input: 0.32, output: 1.28 },
378
- 'accounts/fireworks/models/qwen3p8-2p4t-a95b': { input: 2.00, cachedInput: 0.25, output: 6.00 },
379
- 'qwen/qwen3.8-max': { input: 2.00, output: 6.00 },
380
- // MiniMax
381
- 'MiniMax-M2.5': { input: 0.30, output: 1.20 },
382
- 'MiniMax-M2.7': { input: 0.30, output: 1.20 },
383
- 'MiniMax-M3': { input: 0.30, output: 1.20 },
384
- 'minimax/minimax-m2.7': { input: 0.30, output: 1.20 },
385
- 'minimax/minimax-m3': { input: 0.30, output: 1.20 },
386
- 'MiniMaxAI/MiniMax-M3': { input: 0.30, output: 1.20 },
387
- // Perplexity
388
- 'sonar': { input: 1.00, output: 1.00 },
389
- 'sonar-pro': { input: 3.00, output: 15.00 },
390
- // Hermes 4 (OpenRouter)
391
- 'nousresearch/hermes-4-70b': { input: 0.13, output: 0.40 },
392
- 'nousresearch/hermes-4-405b': { input: 1.00, output: 3.00 },
393
- // Hermes 3 (Lambda/OpenRouter)
394
- 'Hermes-3-Llama-3.1-405B-FP8': { input: 0.80, output: 0.80 },
395
- 'nousresearch/hermes-3-llama-3.1-405b:free': { input: 0, output: 0 },
396
- // Qwen3 (Together/Cerebras)
397
- 'Qwen/Qwen3-235B-A22B-fp8-tput': { input: 0.20, output: 0.60 },
398
- 'qwen-3-32b': { input: 0.20, output: 0.60 },
399
- // Kimi K2.5 (Together/Fireworks/OpenRouter)
400
- 'moonshotai/Kimi-K2.5': { input: 0.50, output: 2.80 },
401
- 'moonshotai/kimi-k2.5': { input: 0.50, output: 2.80 },
402
- // Kimi K3
403
- 'kimi-k3': { input: 3.00, output: 15.00 },
404
- 'moonshotai/kimi-k3': { input: 3.00, output: 15.00 },
405
- // GLM 4.7 (OpenRouter/Cerebras)
406
- 'z-ai/glm-4.7': { input: 0.55, output: 2.19 },
407
- 'zai-glm-4.7': { input: 0.55, output: 2.19 },
408
- };
409
-
410
- const CHAIN_MODEL_SHORTCUTS = new Set([
411
- 'gpt5', 'gpt5mini', 'gpt5nano',
412
- 'gpt51', 'gpt52', 'gpt54', 'gpt54mini', 'gpt54nano', 'gpt54pro',
413
- 'gpt55', 'gpt55pro', 'gpt56sol', 'gpt56terra', 'gpt56luna',
414
- 'gptRealtime', 'gptRealtimeMini', 'gpt53codex', 'gpt53chat', 'gptOss',
415
- 'fable50', 'fable5', 'opus50', 'opus5', 'opus48', 'opus47', 'opus46',
416
- 'sonnet50', 'sonnet5', 'sonnet46', 'sonnet45', 'haiku45',
417
- 'gemini31pro', 'gemini37flash', 'gemini36flash', 'gemini35flash',
418
- 'gemini35flashLite', 'gemini31flashLite', 'sonarPro', 'sonar',
419
- 'grok46', 'grok45', 'grok43', 'grok420multiAgent', 'grok420',
420
- 'qwen3', 'qwen35397b', 'qwen36plus', 'qwen37plus', 'qwen38max',
421
- 'hermes470b', 'hermes4405b', 'hermes3',
422
- 'kimiK26', 'kimiK27Code', 'kimiK3', 'kimiK25',
423
- 'minimaxM27', 'minimaxM3', 'mimo25', 'mimo25pro',
424
- 'deepseekV4Pro', 'deepseekV4Flash', 'GLM51', 'GLM52'
425
- ]);
426
-
427
- function parseChainModels(modelSpecs) {
428
- if (modelSpecs.length === 0) {
429
- throw new TypeError('chain() requires at least one model shortcut string.');
430
- }
431
-
432
- return modelSpecs.map((modelSpec, index) => {
433
- if (typeof modelSpec !== 'string') {
434
- throw new TypeError(`Invalid chain model at index ${index}: expected a model shortcut string.`);
435
- }
436
-
437
- const match = /^([A-Za-z_$][A-Za-z0-9_$]*)(?:@(-?\d+))?$/.exec(modelSpec);
438
- if (!match) {
439
- throw new TypeError(`Invalid chain model "${modelSpec}": expected "shortcut" or "shortcut@effort".`);
440
- }
441
-
442
- const shortcut = match[1];
443
- if (!CHAIN_MODEL_SHORTCUTS.has(shortcut)) {
444
- throw new Error(`Unknown model shortcut "${shortcut}" in chain().`);
445
- }
446
94
 
447
- return {
448
- shortcut,
449
- effort: match[2] === undefined ? undefined : normalizeEffort(Number(match[2]))
450
- };
451
- });
452
- }
453
95
 
454
96
  class ModelMix {
455
97
 
@@ -707,166 +349,28 @@ class ModelMix {
707
349
  return str.length > maxLen ? str.substring(0, maxLen) + '...' : str;
708
350
  }
709
351
 
710
- static normalizeTokenUsage({ input = 0, output = 0, thinking = 0, total, cached = 0, cacheWrite = 0, cacheWrite5m = 0, cacheWrite1h = 0 } = {}) {
711
- const tokenCount = value => Number.isFinite(value) ? Math.max(0, value) : 0;
712
- const normalizedInput = tokenCount(input);
713
- const normalizedOutput = tokenCount(output);
714
- const normalizedThinking = tokenCount(thinking);
715
- const normalizedCached = tokenCount(cached);
716
- const normalizedCacheWrite5m = tokenCount(cacheWrite5m);
717
- const normalizedCacheWrite1h = tokenCount(cacheWrite1h);
718
- const normalizedCacheWrite = Math.max(
719
- tokenCount(cacheWrite),
720
- normalizedCacheWrite5m + normalizedCacheWrite1h
721
- );
722
- const normalizedTotal = Number.isFinite(total)
723
- ? Math.max(0, total)
724
- : normalizedInput + normalizedOutput + normalizedThinking;
725
- const uncachedInput = Math.max(0, normalizedInput - normalizedCached - normalizedCacheWrite);
726
- const cacheHitRate = normalizedInput > 0
727
- ? Number((normalizedCached / normalizedInput).toFixed(4))
728
- : 0;
729
-
730
- return {
731
- input: normalizedInput,
732
- output: normalizedOutput,
733
- thinking: normalizedThinking,
734
- total: normalizedTotal,
735
- cached: normalizedCached,
736
- cacheWrite: normalizedCacheWrite,
737
- cacheWrite5m: normalizedCacheWrite5m,
738
- cacheWrite1h: normalizedCacheWrite1h,
739
- uncachedInput,
740
- cacheHitRate,
741
- cacheSavings: 0,
742
- cacheWritePremium: 0,
743
- breakEvenHits: 0,
744
- cost: 0,
745
- costBreakdown: {
746
- uncachedInput: 0,
747
- cachedInput: 0,
748
- cacheWrite: 0,
749
- cacheWrite5m: 0,
750
- cacheWrite1h: 0,
751
- output: 0,
752
- total: 0
753
- }
754
- };
352
+ static normalizeTokenUsage(usage = {}) {
353
+ return tokenUsage.normalizeTokenUsage(usage);
755
354
  }
756
355
 
757
356
  static calculateCostBreakdown(modelKey, tokens) {
758
- const pricing = MODEL_PRICING[modelKey];
759
- if (!pricing) return ModelMix.normalizeTokenUsage().costBreakdown;
760
-
761
- const normalized = ModelMix.normalizeTokenUsage(tokens);
762
- const longContext = pricing.longContext;
763
- const useLongContextRates = usesLongContextRates(pricing, normalized.input);
764
- const inputMultiplier = useLongContextRates ? longContext.inputMultiplier : 1;
765
- const outputMultiplier = useLongContextRates ? longContext.outputMultiplier : 1;
766
- const {
767
- input: inputPerMillion,
768
- cachedInput: cachedInputPerMillion = inputPerMillion,
769
- cacheWrite: cacheWritePerMillion = inputPerMillion,
770
- cacheWrite1h: cacheWrite1hPerMillion = cacheWritePerMillion,
771
- output: outputPerMillion
772
- } = pricing;
773
- const roundCost = value => Number(value.toFixed(12));
774
- const genericCacheWrite = Math.max(
775
- 0,
776
- normalized.cacheWrite - normalized.cacheWrite5m - normalized.cacheWrite1h
777
- );
778
- const cacheWrite5mCost = roundCost(
779
- normalized.cacheWrite5m * cacheWritePerMillion * inputMultiplier / 1_000_000
780
- );
781
- const cacheWrite1hCost = roundCost(
782
- normalized.cacheWrite1h * cacheWrite1hPerMillion * inputMultiplier / 1_000_000
783
- );
784
- const genericCacheWriteCost = roundCost(
785
- genericCacheWrite * cacheWritePerMillion * inputMultiplier / 1_000_000
786
- );
787
- const breakdown = {
788
- uncachedInput: roundCost(normalized.uncachedInput * inputPerMillion * inputMultiplier / 1_000_000),
789
- cachedInput: roundCost(normalized.cached * cachedInputPerMillion * inputMultiplier / 1_000_000),
790
- cacheWrite: roundCost(genericCacheWriteCost + cacheWrite5mCost + cacheWrite1hCost),
791
- cacheWrite5m: cacheWrite5mCost,
792
- cacheWrite1h: cacheWrite1hCost,
793
- output: roundCost(
794
- (normalized.output + normalized.thinking) * outputPerMillion * outputMultiplier / 1_000_000
795
- )
796
- };
797
- breakdown.total = roundCost(
798
- breakdown.uncachedInput
799
- + breakdown.cachedInput
800
- + breakdown.cacheWrite
801
- + breakdown.output
802
- );
803
- return breakdown;
357
+ return tokenUsage.calculateCostBreakdown(modelKey, tokens);
804
358
  }
805
359
 
806
360
  static calculateCacheMetrics(modelKey, tokens) {
807
- const pricing = MODEL_PRICING[modelKey];
808
- const emptyMetrics = {
809
- cacheSavings: 0,
810
- cacheWritePremium: 0,
811
- breakEvenHits: 0
812
- };
813
- if (!pricing) return emptyMetrics;
814
-
815
- const normalized = ModelMix.normalizeTokenUsage(tokens);
816
- const longContext = pricing.longContext;
817
- const useLongContextRates = usesLongContextRates(pricing, normalized.input);
818
- const inputMultiplier = useLongContextRates ? longContext.inputMultiplier : 1;
819
- const cachedInputPerMillion = pricing.cachedInput ?? pricing.input;
820
- const cacheWritePerMillion = pricing.cacheWrite ?? pricing.input;
821
- const cacheWrite1hPerMillion = pricing.cacheWrite1h ?? cacheWritePerMillion;
822
- const readSavingsPerMillion = Math.max(0, pricing.input - cachedInputPerMillion) * inputMultiplier;
823
- const writePremiumPerMillion = Math.max(0, cacheWritePerMillion - pricing.input) * inputMultiplier;
824
- const write1hPremiumPerMillion = Math.max(0, cacheWrite1hPerMillion - pricing.input) * inputMultiplier;
825
- const roundCost = value => Number(value.toFixed(12));
826
- const cacheSavings = roundCost(normalized.cached * readSavingsPerMillion / 1_000_000);
827
- const genericCacheWrite = Math.max(
828
- 0,
829
- normalized.cacheWrite - normalized.cacheWrite5m - normalized.cacheWrite1h
830
- );
831
- const cacheWritePremium = roundCost(
832
- (
833
- (genericCacheWrite + normalized.cacheWrite5m) * writePremiumPerMillion
834
- + normalized.cacheWrite1h * write1hPremiumPerMillion
835
- ) / 1_000_000
836
- );
837
- const fullHitSavings = normalized.cacheWrite * readSavingsPerMillion / 1_000_000;
838
-
839
- return {
840
- cacheSavings,
841
- cacheWritePremium,
842
- breakEvenHits: fullHitSavings > 0
843
- ? Number((cacheWritePremium / fullHitSavings).toFixed(4))
844
- : 0
845
- };
361
+ return tokenUsage.calculateCacheMetrics(modelKey, tokens);
846
362
  }
847
363
 
848
364
  static calculateCost(modelKey, tokens) {
849
- if (!MODEL_PRICING[modelKey]) return null;
850
- return ModelMix.calculateCostBreakdown(modelKey, tokens).total;
365
+ return tokenUsage.calculateCost(modelKey, tokens);
851
366
  }
852
367
 
853
368
  static extractCacheTokens(usage = {}) {
854
- return usage.input_tokens_details?.cached_tokens
855
- ?? usage.prompt_tokens_details?.cached_tokens
856
- ?? usage.cache_read_input_tokens
857
- ?? usage.cachedContentTokenCount
858
- ?? usage.cached_content_token_count
859
- ?? 0;
369
+ return tokenUsage.extractCacheTokens(usage);
860
370
  }
861
371
 
862
372
  static extractCacheWriteTokens(usage = {}) {
863
- return usage.input_tokens_details?.cache_write_tokens
864
- ?? usage.prompt_tokens_details?.cache_write_tokens
865
- ?? usage.cache_creation_input_tokens
866
- ?? usage.cache_write_input_tokens
867
- ?? usage.cacheWriteTokenCount
868
- ?? usage.cache_write_token_count
869
- ?? 0;
373
+ return tokenUsage.extractCacheWriteTokens(usage);
870
374
  }
871
375
 
872
376
  static formatInputSummary(messages, system, debug = 2) {
@@ -917,7 +421,8 @@ class ModelMix {
917
421
 
918
422
  attach(key, provider) {
919
423
 
920
- if (this.models.some(model => model.key === key)) {
424
+ if (this.models.some(model => model.key === key
425
+ && model.provider.constructor === provider.constructor)) {
921
426
  return this;
922
427
  }
923
428
 
@@ -988,7 +493,7 @@ class ModelMix {
988
493
  if (mix.together) this.attach('openai/gpt-oss-120b', new MixTogether({ options, config }));
989
494
  if (mix.cerebras) this.attach('gpt-oss-120b', new MixCerebras({ options, config }));
990
495
  if (mix.groq) this.attach('openai/gpt-oss-120b', new MixGroq({ options, config }));
991
- if (mix.openrouter) this.attach('openai/gpt-oss-120b:free', new MixOpenRouter({ options, config }));
496
+ if (mix.openrouter) this.attach('openai/gpt-oss-120b', new MixOpenRouter({ options, config }));
992
497
  return this;
993
498
  }
994
499
  fable50({ options = {}, config = {} } = {}) {
@@ -1675,439 +1180,482 @@ class ModelMix {
1675
1180
  return this.systemTemplate;
1676
1181
  }
1677
1182
 
1678
- async execute({
1679
- config = {},
1680
- options = {},
1681
- systemSuffix = '',
1682
- outputMode = 'raw',
1683
- _templateContext = null,
1684
- _pluginRequest = null,
1685
- _executionMetadata = null,
1686
- _pluginsApplied = false
1687
- } = {}) {
1688
- const isRootExecution = _templateContext === null;
1689
- const templateContext = _templateContext || createTemplateRenderContext(() => this._choiceRandom());
1690
-
1691
- if (!_pluginsApplied && this.plugins.length > 0) {
1692
- const preparedMessages = await this.prepareMessages(templateContext);
1693
- if (preparedMessages.length === 0) {
1694
- throw new Error("No user messages have been added. Use addText(prompt), addTextFromFile(filePath), addImage(filePath), or addImageFromUrl(url) to add a prompt.");
1695
- }
1696
- const requestConfig = {
1697
- ...this.config,
1698
- ...config,
1699
- retry: {
1700
- ...(this.config.retry || {}),
1701
- ...(config.retry || {})
1702
- }
1703
- };
1704
- const systemTemplate = this._resolveSystemTemplate(config, {});
1705
- const systemCacheKey = JSON.stringify([systemTemplate.filename, systemTemplate.source]);
1706
- if (!templateContext.renderedSystems.has(systemCacheKey)) {
1707
- templateContext.renderedSystems.set(
1708
- systemCacheKey,
1709
- this._renderTemplate(systemTemplate.source, {
1710
- filename: systemTemplate.filename,
1711
- label: 'system template'
1712
- }, templateContext)
1713
- );
1183
+ _mergeRequestConfig(config = {}) {
1184
+ return {
1185
+ ...this.config,
1186
+ ...config,
1187
+ retry: {
1188
+ ...(this.config.retry || {}),
1189
+ ...(config.retry || {})
1714
1190
  }
1715
- const request = {
1716
- system: templateContext.renderedSystems.get(systemCacheKey) + systemSuffix,
1717
- messages: clonePluginValue(preparedMessages),
1718
- options: clonePluginValue({ ...this.options, ...options }),
1719
- config: clonePluginValue(requestConfig),
1720
- outputMode
1721
- };
1722
- const executionMetadata = _executionMetadata || {
1723
- executionId: randomUUID(),
1724
- parentExecutionId: null,
1725
- depth: 0
1726
- };
1727
- let providerInvoked = false;
1728
-
1729
- const dispatch = async index => {
1730
- if (index === this.plugins.length) {
1731
- providerInvoked = true;
1732
- return this.execute({
1733
- config,
1734
- options,
1735
- systemSuffix,
1736
- outputMode,
1737
- _templateContext: templateContext,
1738
- _pluginRequest: request,
1739
- _executionMetadata: executionMetadata,
1740
- _pluginsApplied: true
1741
- });
1742
- }
1743
-
1744
- const plugin = this.plugins[index];
1745
- let nextCalled = false;
1746
- const next = () => {
1747
- if (nextCalled) {
1748
- throw new Error(`Plugin "${plugin.name}" called next() multiple times.`);
1749
- }
1750
- nextCalled = true;
1751
- return dispatch(index + 1);
1752
- };
1753
- const context = {
1754
- request,
1755
- execution: Object.freeze({ ...executionMetadata }),
1756
- invoke: input => this._invokeChild(input, executionMetadata)
1757
- };
1758
- const result = await plugin.execute(context, next);
1759
- return validatePluginResult(result, plugin.name);
1760
- };
1191
+ };
1192
+ }
1761
1193
 
1762
- const result = await dispatch(0);
1763
- this.lastRaw = result;
1764
- if (!providerInvoked) {
1765
- if (this.config.max_history === 0) {
1766
- this.messages = [];
1767
- } else if (result.message) {
1768
- this._addText(result.message, { role: 'assistant' });
1769
- }
1770
- }
1771
- if (isRootExecution) this._commitTemplateRenderContext(templateContext);
1772
- return result;
1194
+ _requirePreparedMessages(messages) {
1195
+ if (messages.length === 0) {
1196
+ throw new Error("No user messages have been added. Use addText(prompt), addTextFromFile(filePath), addImage(filePath), or addImageFromUrl(url) to add a prompt.");
1773
1197
  }
1198
+ }
1774
1199
 
1775
- if (!this.models || this.models.length === 0) {
1776
- throw new Error("No models specified. Use methods like .gpt5(), .sonnet46() first.");
1200
+ _renderSystem(config, providerConfig, systemSuffix, templateContext) {
1201
+ const systemTemplate = this._resolveSystemTemplate(config, providerConfig);
1202
+ const systemCacheKey = JSON.stringify([systemTemplate.filename, systemTemplate.source]);
1203
+ if (!templateContext.renderedSystems.has(systemCacheKey)) {
1204
+ templateContext.renderedSystems.set(
1205
+ systemCacheKey,
1206
+ this._renderTemplate(systemTemplate.source, {
1207
+ filename: systemTemplate.filename,
1208
+ label: 'system template'
1209
+ }, templateContext)
1210
+ );
1777
1211
  }
1212
+ return templateContext.renderedSystems.get(systemCacheKey) + systemSuffix;
1213
+ }
1778
1214
 
1779
- const execution = this.limiter.schedule(async () => {
1780
- const preparedMessages = _pluginRequest
1781
- ? _pluginRequest.messages
1782
- : await this.prepareMessages(templateContext);
1215
+ async _executePlugins({
1216
+ config,
1217
+ options,
1218
+ systemSuffix,
1219
+ outputMode,
1220
+ templateContext,
1221
+ executionMetadata,
1222
+ isRootExecution
1223
+ }) {
1224
+ const preparedMessages = await this.prepareMessages(templateContext);
1225
+ this._requirePreparedMessages(preparedMessages);
1783
1226
 
1784
- if (preparedMessages.length === 0) {
1785
- throw new Error("No user messages have been added. Use addText(prompt), addTextFromFile(filePath), addImage(filePath), or addImageFromUrl(url) to add a prompt.");
1227
+ const request = {
1228
+ system: this._renderSystem(config, {}, systemSuffix, templateContext),
1229
+ messages: clonePluginValue(preparedMessages),
1230
+ options: clonePluginValue({ ...this.options, ...options }),
1231
+ config: clonePluginValue(this._mergeRequestConfig(config)),
1232
+ outputMode
1233
+ };
1234
+ const metadata = executionMetadata || {
1235
+ executionId: randomUUID(),
1236
+ parentExecutionId: null,
1237
+ depth: 0
1238
+ };
1239
+ let providerInvoked = false;
1240
+
1241
+ const dispatch = async index => {
1242
+ if (index === this.plugins.length) {
1243
+ providerInvoked = true;
1244
+ return this.execute({
1245
+ config,
1246
+ options,
1247
+ systemSuffix,
1248
+ outputMode,
1249
+ _templateContext: templateContext,
1250
+ _pluginRequest: request,
1251
+ _executionMetadata: metadata,
1252
+ _pluginsApplied: true
1253
+ });
1786
1254
  }
1787
1255
 
1788
- // Merge config to get final roundRobin value and retry settings
1789
- const finalConfig = _pluginRequest
1790
- ? _pluginRequest.config
1791
- : {
1792
- ...this.config,
1793
- ...config,
1794
- retry: {
1795
- ...(this.config.retry || {}),
1796
- ...(config.retry || {})
1797
- }
1798
- };
1799
-
1800
- // Try all models in order (first is primary, rest are fallbacks)
1801
- const modelsToTry = this.models.map((model, index) => ({ model, index }));
1256
+ const plugin = this.plugins[index];
1257
+ let nextCalled = false;
1258
+ const next = () => {
1259
+ if (nextCalled) {
1260
+ throw new Error(`Plugin "${plugin.name}" called next() multiple times.`);
1261
+ }
1262
+ nextCalled = true;
1263
+ return dispatch(index + 1);
1264
+ };
1265
+ const context = {
1266
+ request,
1267
+ execution: Object.freeze({ ...metadata }),
1268
+ invoke: input => this._invokeChild(input, metadata)
1269
+ };
1270
+ const result = await plugin.execute(context, next);
1271
+ return validatePluginResult(result, plugin.name);
1272
+ };
1802
1273
 
1803
- // Round robin: rotate models array AFTER using current for next request
1804
- if (finalConfig.roundRobin && this.models.length > 1) {
1805
- const firstModel = this.models.shift();
1806
- this.models.push(firstModel);
1274
+ const result = await dispatch(0);
1275
+ this.lastRaw = result;
1276
+ if (!providerInvoked) {
1277
+ if (this.config.max_history === 0) {
1278
+ this.messages = [];
1279
+ } else if (result.message) {
1280
+ this._addText(result.message, { role: 'assistant' });
1807
1281
  }
1282
+ }
1283
+ if (isRootExecution) this._commitTemplateRenderContext(templateContext);
1284
+ return result;
1285
+ }
1808
1286
 
1809
- let lastError = null;
1810
-
1811
- for (let i = 0; i < modelsToTry.length; i++) {
1812
-
1813
- const { model: currentModel, index: originalIndex } = modelsToTry[i];
1814
- const currentModelKey = currentModel.key;
1815
- const providerInstance = currentModel.provider;
1816
- const optionsTools = providerInstance.getOptionsTools(this.tools);
1817
-
1818
- // Create clean copies for each provider to avoid contamination
1819
- const currentOptions = {
1820
- ...this.options,
1821
- messages: preparedMessages,
1822
- ...providerInstance.options,
1823
- ...optionsTools,
1824
- ...options,
1825
- ...(_pluginRequest?.options || {}),
1826
- model: currentModelKey
1827
- };
1828
-
1829
- const currentConfig = _pluginRequest
1830
- ? {
1831
- ...providerInstance.config,
1832
- ..._pluginRequest.config,
1833
- retry: {
1834
- ...(providerInstance.config?.retry || {}),
1835
- ...(_pluginRequest.config.retry || {})
1836
- }
1837
- }
1838
- : {
1839
- ...finalConfig,
1840
- ...providerInstance.config,
1841
- ...config,
1842
- retry: {
1843
- ...(finalConfig.retry || {}),
1844
- ...(providerInstance.config?.retry || {}),
1845
- ...(config.retry || {})
1846
- }
1847
- };
1848
- if (_pluginRequest) {
1849
- currentConfig.system = _pluginRequest.system;
1850
- } else {
1851
- const systemTemplate = this._resolveSystemTemplate(config, providerInstance.config);
1852
- const systemCacheKey = JSON.stringify([systemTemplate.filename, systemTemplate.source]);
1853
- if (!templateContext.renderedSystems.has(systemCacheKey)) {
1854
- templateContext.renderedSystems.set(
1855
- systemCacheKey,
1856
- this._renderTemplate(systemTemplate.source, {
1857
- filename: systemTemplate.filename,
1858
- label: 'system template'
1859
- }, templateContext)
1860
- );
1861
- }
1862
- currentConfig.system = templateContext.renderedSystems.get(systemCacheKey) + systemSuffix;
1287
+ _createProviderAttempt({
1288
+ currentModel,
1289
+ preparedMessages,
1290
+ config,
1291
+ options,
1292
+ finalConfig,
1293
+ pluginRequest,
1294
+ systemSuffix,
1295
+ templateContext
1296
+ }) {
1297
+ const provider = currentModel.provider;
1298
+ const currentOptions = {
1299
+ ...this.options,
1300
+ messages: preparedMessages,
1301
+ ...provider.options,
1302
+ ...provider.getOptionsTools(this.tools),
1303
+ ...options,
1304
+ ...(pluginRequest?.options || {}),
1305
+ model: currentModel.key
1306
+ };
1307
+ const currentConfig = pluginRequest
1308
+ ? {
1309
+ ...provider.config,
1310
+ ...pluginRequest.config,
1311
+ retry: {
1312
+ ...(provider.config?.retry || {}),
1313
+ ...(pluginRequest.config.retry || {})
1314
+ }
1315
+ }
1316
+ : {
1317
+ ...finalConfig,
1318
+ ...provider.config,
1319
+ ...config,
1320
+ retry: {
1321
+ ...(finalConfig.retry || {}),
1322
+ ...(provider.config?.retry || {}),
1323
+ ...(config.retry || {})
1863
1324
  }
1325
+ };
1864
1326
 
1865
- // Grok 4.20 alias → reasoning / non-reasoning from unified effort
1866
- const resolvedModelKey = resolveGrok420ModelKey(
1867
- currentModelKey,
1868
- currentConfig.effort,
1869
- currentOptions
1870
- );
1871
- currentOptions.model = resolvedModelKey;
1327
+ currentConfig.system = pluginRequest
1328
+ ? pluginRequest.system
1329
+ : this._renderSystem(config, provider.config, systemSuffix, templateContext);
1872
1330
 
1873
- // Unified effort → native provider fields (skipped if native already set)
1874
- const providerFamily = resolveProviderFamily(providerInstance);
1875
- applyUnifiedEffort(currentOptions, currentConfig, providerFamily, resolvedModelKey);
1331
+ const resolvedModelKey = resolveGrok420ModelKey(
1332
+ currentModel.key,
1333
+ currentConfig.effort,
1334
+ currentOptions
1335
+ );
1336
+ currentOptions.model = resolvedModelKey;
1337
+ applyUnifiedEffort(
1338
+ currentOptions,
1339
+ currentConfig,
1340
+ resolveProviderFamily(provider),
1341
+ resolvedModelKey
1342
+ );
1876
1343
 
1877
- if (currentConfig.debug >= 1) {
1878
- const isPrimary = i === 0;
1879
- const prefix = isPrimary ? '→' : '↻';
1880
- const suffix = isPrimary
1881
- ? (currentConfig.roundRobin ? ` (round-robin #${originalIndex + 1})` : '')
1882
- : ' (fallback)';
1883
- // Extract provider name from class name (e.g., "MixOpenRouter" -> "openrouter")
1884
- const providerName = providerInstance.constructor.name.replace(/^Mix/, '').toLowerCase();
1885
- const header = `\n${prefix} [${providerName}:${resolvedModelKey}] #${originalIndex + 1}${suffix}`;
1886
-
1887
- if (currentConfig.debug >= 2) {
1888
- console.log(`${header}\n${ModelMix.formatInputSummary(preparedMessages, currentConfig.system, currentConfig.debug)}`);
1889
- } else {
1890
- console.log(header);
1891
- }
1892
- }
1344
+ return { provider, currentOptions, currentConfig, resolvedModelKey };
1345
+ }
1893
1346
 
1894
- try {
1895
- if (currentOptions.stream && this.streamCallback) {
1896
- providerInstance.streamCallback = this.streamCallback;
1897
- }
1347
+ _logProviderAttempt({ attempt, originalIndex, provider, currentConfig, resolvedModelKey, preparedMessages }) {
1348
+ if (currentConfig.debug < 1) return;
1898
1349
 
1899
- const retryConfig = currentConfig.retry || {};
1900
- const retries = retryConfig.enabled ? Math.max(0, retryConfig.retries || 0) : 0;
1901
- const baseDelayMs = Math.max(0, retryConfig.baseDelayMs || 0);
1902
- const maxDelayMs = Math.max(baseDelayMs, retryConfig.maxDelayMs || baseDelayMs);
1903
- const retryableStatusCodes = new Set(
1904
- Array.isArray(retryConfig.retryableStatusCodes) && retryConfig.retryableStatusCodes.length > 0
1905
- ? retryConfig.retryableStatusCodes
1906
- : DEFAULT_RETRYABLE_STATUS_CODES
1907
- );
1908
-
1909
- let attempt = 0;
1910
- let result;
1911
- let startTime = 0;
1912
-
1913
- while (true) {
1914
- try {
1915
- startTime = Date.now();
1916
- result = await providerInstance.create({ options: currentOptions, config: currentConfig });
1917
- break;
1918
- } catch (attemptError) {
1919
- const statusCode = getErrorStatusCode(attemptError);
1920
- const isRetryable = retryableStatusCodes.has(statusCode);
1921
- const canRetry = attempt < retries && isRetryable;
1922
-
1923
- if (!canRetry) {
1924
- throw attemptError;
1925
- }
1926
-
1927
- if (currentConfig.debug >= 1) {
1928
- const nextAttempt = attempt + 2;
1929
- const totalAttempts = retries + 1;
1930
- console.log(`↺ Retrying [${resolvedModelKey}] due to status ${statusCode} (${nextAttempt}/${totalAttempts})`);
1931
- }
1932
-
1933
- const delay = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
1934
- await sleep(delay);
1935
- attempt += 1;
1936
- }
1937
- }
1350
+ const isPrimary = attempt === 0;
1351
+ const prefix = isPrimary ? '→' : '↻';
1352
+ const suffix = isPrimary
1353
+ ? (currentConfig.roundRobin ? ` (round-robin #${originalIndex + 1})` : '')
1354
+ : ' (fallback)';
1355
+ const providerName = provider.constructor.name.replace(/^Mix/, '').toLowerCase();
1356
+ const effort = currentConfig.effort === undefined ? '' : `@${currentConfig.effort}`;
1357
+ const header = `\n${prefix} [${providerName}:${resolvedModelKey}${effort}] #${originalIndex + 1}${suffix}`;
1938
1358
 
1939
- const elapsedMs = Date.now() - startTime;
1940
-
1941
- if (result.tokens) {
1942
- const normalizedTokens = ModelMix.normalizeTokenUsage(result.tokens);
1943
- const costBreakdown = ModelMix.calculateCostBreakdown(resolvedModelKey, normalizedTokens);
1944
- const cacheMetrics = ModelMix.calculateCacheMetrics(resolvedModelKey, normalizedTokens);
1945
- result.tokens = {
1946
- ...result.tokens,
1947
- ...normalizedTokens,
1948
- ...cacheMetrics,
1949
- cost: MODEL_PRICING[resolvedModelKey] ? costBreakdown.total : 0,
1950
- costBreakdown
1951
- };
1952
- const elapsedSec = elapsedMs / 1000;
1953
- result.tokens.speed = elapsedSec > 0 ? Math.round(result.tokens.output / elapsedSec) : 0;
1954
- }
1359
+ if (currentConfig.debug >= 2) {
1360
+ console.log(`${header}\n${ModelMix.formatInputSummary(preparedMessages, currentConfig.system, currentConfig.debug)}`);
1361
+ } else {
1362
+ console.log(header);
1363
+ }
1364
+ }
1955
1365
 
1956
- if (result.toolCalls && result.toolCalls.length > 0) {
1957
- const toolMessages = _pluginRequest
1958
- ? clonePluginValue(_pluginRequest.messages)
1959
- : this.messages;
1960
- if (result.assistantMessage) {
1961
- toolMessages.push(result.assistantMessage);
1962
- } else if (result.message) {
1963
- if (result.signature) {
1964
- toolMessages.push({
1965
- role: "assistant", content: [{
1966
- type: "thinking",
1967
- // Empty string is valid (Anthropic display: "omitted").
1968
- thinking: result.think ?? '',
1969
- signature: result.signature
1970
- }]
1971
- });
1972
- } else {
1973
- toolMessages.push({
1974
- role: 'assistant',
1975
- content: [{ type: 'text', text: result.message }]
1976
- });
1977
- }
1978
- }
1366
+ async _invokeProviderWithRetry(provider, currentOptions, currentConfig, resolvedModelKey) {
1367
+ if (currentOptions.stream && this.streamCallback) {
1368
+ provider.streamCallback = this.streamCallback;
1369
+ }
1979
1370
 
1980
- if (!result.assistantMessage) {
1981
- toolMessages.push({ role: "assistant", content: null, tool_calls: result.toolCalls });
1982
- }
1371
+ const retryConfig = currentConfig.retry || {};
1372
+ const retries = retryConfig.enabled ? Math.max(0, retryConfig.retries || 0) : 0;
1373
+ const baseDelayMs = Math.max(0, retryConfig.baseDelayMs || 0);
1374
+ const maxDelayMs = Math.max(baseDelayMs, retryConfig.maxDelayMs || baseDelayMs);
1375
+ const retryableStatusCodes = new Set(
1376
+ Array.isArray(retryConfig.retryableStatusCodes) && retryConfig.retryableStatusCodes.length > 0
1377
+ ? retryConfig.retryableStatusCodes
1378
+ : DEFAULT_RETRYABLE_STATUS_CODES
1379
+ );
1983
1380
 
1984
- const toolResults = await this.processToolCalls(result.toolCalls);
1985
- for (const toolResult of toolResults) {
1986
- toolMessages.push({
1987
- role: 'tool',
1988
- tool_call_id: toolResult.tool_call_id,
1989
- name: toolResult.name,
1990
- content: toolResult.content
1991
- });
1992
- }
1993
- this.messages = toolMessages;
1994
-
1995
- const nextPluginRequest = _pluginRequest
1996
- ? {
1997
- ..._pluginRequest,
1998
- messages: toolMessages
1999
- }
2000
- : null;
2001
- return this.execute({
2002
- options,
2003
- config,
2004
- systemSuffix,
2005
- outputMode,
2006
- _templateContext: templateContext,
2007
- _pluginRequest: nextPluginRequest,
2008
- _executionMetadata,
2009
- _pluginsApplied
2010
- });
2011
- }
1381
+ let attempt = 0;
1382
+ while (true) {
1383
+ const startTime = Date.now();
1384
+ try {
1385
+ const result = await provider.create({ options: currentOptions, config: currentConfig });
1386
+ return { result, elapsedMs: Date.now() - startTime };
1387
+ } catch (error) {
1388
+ const statusCode = getErrorStatusCode(error);
1389
+ if (attempt >= retries || !retryableStatusCodes.has(statusCode)) throw error;
2012
1390
 
2013
- // debug level 1: Just success indicator
2014
- if (currentConfig.debug === 1) {
2015
- console.log(`✓ Success`);
2016
- }
1391
+ if (currentConfig.debug >= 1) {
1392
+ console.log(`↺ Retrying [${resolvedModelKey}] due to status ${statusCode} (${attempt + 2}/${retries + 1})`);
1393
+ }
1394
+ const delay = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
1395
+ await sleep(delay);
1396
+ attempt += 1;
1397
+ }
1398
+ }
1399
+ }
2017
1400
 
2018
- // debug level 2: Readable summary of output
2019
- if (currentConfig.debug >= 2) {
2020
- const tokenInfo = result.tokens
2021
- ? ` ${result.tokens.input} → ${result.tokens.output} tok`
2022
- + (result.tokens.cached ? ` (cached:${result.tokens.cached})` : '')
2023
- + (result.tokens.speed ? ` | ${result.tokens.speed} t/s` : '')
2024
- + (result.tokens.cost != null ? ` $${result.tokens.cost.toFixed(4)}` : '')
2025
- : '';
2026
- console.log(`✓${tokenInfo}\n${ModelMix.formatOutputSummary(result, currentConfig.debug).trim()}`);
2027
- }
1401
+ _enrichResultTokens(result, resolvedModelKey, elapsedMs) {
1402
+ if (!result.tokens) return;
2028
1403
 
2029
- // debug level 4 (verbose): Full response details
2030
- if (currentConfig.debug >= 4) {
2031
- if (result.response) {
2032
- console.log('\n[RAW RESPONSE]');
2033
- console.log(ModelMix.formatJSON(result.response));
2034
- }
1404
+ const normalizedTokens = ModelMix.normalizeTokenUsage(result.tokens);
1405
+ const costBreakdown = ModelMix.calculateCostBreakdown(resolvedModelKey, normalizedTokens);
1406
+ const cacheMetrics = ModelMix.calculateCacheMetrics(resolvedModelKey, normalizedTokens);
1407
+ result.tokens = {
1408
+ ...result.tokens,
1409
+ ...normalizedTokens,
1410
+ ...cacheMetrics,
1411
+ cost: tokenUsage.hasModelPricing(resolvedModelKey) ? costBreakdown.total : 0,
1412
+ costBreakdown
1413
+ };
1414
+ const elapsedSec = elapsedMs / 1000;
1415
+ result.tokens.speed = elapsedSec > 0 ? Math.round(result.tokens.output / elapsedSec) : 0;
1416
+ }
1417
+
1418
+ async _continueToolCalls(result, pluginRequest, execution) {
1419
+ const toolMessages = pluginRequest
1420
+ ? clonePluginValue(pluginRequest.messages)
1421
+ : this.messages;
1422
+ if (result.assistantMessage) {
1423
+ toolMessages.push(result.assistantMessage);
1424
+ } else if (result.message) {
1425
+ if (result.signature) {
1426
+ toolMessages.push({
1427
+ role: 'assistant',
1428
+ content: [{
1429
+ type: 'thinking',
1430
+ thinking: result.think ?? '',
1431
+ signature: result.signature
1432
+ }]
1433
+ });
1434
+ } else {
1435
+ toolMessages.push({
1436
+ role: 'assistant',
1437
+ content: [{ type: 'text', text: result.message }]
1438
+ });
1439
+ }
1440
+ }
2035
1441
 
2036
- if (result.message) {
2037
- console.log('\n[FULL MESSAGE]');
2038
- console.log(ModelMix.formatMessage(result.message));
2039
- }
1442
+ if (!result.assistantMessage) {
1443
+ toolMessages.push({ role: 'assistant', content: null, tool_calls: result.toolCalls });
1444
+ }
1445
+ const toolResults = await this.processToolCalls(result.toolCalls);
1446
+ for (const toolResult of toolResults) {
1447
+ toolMessages.push({
1448
+ role: 'tool',
1449
+ tool_call_id: toolResult.tool_call_id,
1450
+ name: toolResult.name,
1451
+ content: toolResult.content
1452
+ });
1453
+ }
1454
+ this.messages = toolMessages;
2040
1455
 
2041
- if (result.think) {
2042
- console.log('\n[FULL THINKING]');
2043
- console.log(result.think);
2044
- }
2045
- }
1456
+ return this.execute({
1457
+ ...execution,
1458
+ _pluginRequest: pluginRequest
1459
+ ? { ...pluginRequest, messages: toolMessages }
1460
+ : null
1461
+ });
1462
+ }
2046
1463
 
2047
- if (currentConfig.debug >= 1) console.log('');
2048
-
2049
- this.lastRaw = result;
2050
-
2051
- // Manage conversation history based on max_history setting
2052
- if (this.config.max_history === 0) {
2053
- // Stateless: clear messages so next call starts fresh
2054
- this.messages = [];
2055
- } else if (result.message) {
2056
- // Persist assistant response for multi-turn conversations
2057
- if (result.assistantMessage) {
2058
- this.messages.push(result.assistantMessage);
2059
- } else if (result.signature) {
2060
- this.messages.push({
2061
- role: "assistant", content: [{
2062
- type: "thinking",
2063
- // Empty string is valid (Anthropic display: "omitted").
2064
- thinking: result.think ?? '',
2065
- signature: result.signature
2066
- }, {
2067
- type: "text",
2068
- text: result.message
2069
- }]
2070
- });
2071
- } else {
2072
- this._addText(result.message, { role: "assistant" });
2073
- }
2074
- }
1464
+ _logProviderSuccess(result, currentConfig) {
1465
+ if (currentConfig.debug === 1) console.log('✓ Success');
2075
1466
 
2076
- return result;
1467
+ if (currentConfig.debug >= 2) {
1468
+ const tokenInfo = result.tokens
1469
+ ? ` ${result.tokens.input} → ${result.tokens.output} tok`
1470
+ + (result.tokens.cached ? ` (cached:${result.tokens.cached})` : '')
1471
+ + (result.tokens.speed ? ` | ${result.tokens.speed} t/s` : '')
1472
+ + (result.tokens.cost != null ? ` $${result.tokens.cost.toFixed(4)}` : '')
1473
+ : '';
1474
+ console.log(`✓${tokenInfo}\n${ModelMix.formatOutputSummary(result, currentConfig.debug).trim()}`);
1475
+ }
2077
1476
 
2078
- } catch (error) {
2079
- lastError = error;
2080
- log.warn(`Model ${currentModelKey} failed (Attempt #${i + 1}/${modelsToTry.length}).`);
2081
- if (error.message) log.warn(`Error: ${error.message}`);
2082
- if (error.statusCode) log.warn(`Status Code: ${error.statusCode}`);
2083
- if (error.details) log.warn(`Details:\n${ModelMix.formatJSON(error.details)}`);
2084
-
2085
- if (i === modelsToTry.length - 1) {
2086
- console.error(`All ${modelsToTry.length} model(s) failed. Throwing last error from ${currentModelKey}.`);
2087
- throw lastError;
2088
- } else {
2089
- const nextModelKey = modelsToTry[i + 1].model.key;
2090
- log.info(`-> Proceeding to next model: ${nextModelKey}`);
2091
- }
2092
- }
1477
+ if (currentConfig.debug >= 4) {
1478
+ if (result.response) {
1479
+ console.log('\n[RAW RESPONSE]');
1480
+ console.log(ModelMix.formatJSON(result.response));
2093
1481
  }
1482
+ if (result.message) {
1483
+ console.log('\n[FULL MESSAGE]');
1484
+ console.log(ModelMix.formatMessage(result.message));
1485
+ }
1486
+ if (result.think) {
1487
+ console.log('\n[FULL THINKING]');
1488
+ console.log(result.think);
1489
+ }
1490
+ }
2094
1491
 
2095
- log.error("Fallback logic completed without success or throwing the final error.");
2096
- throw lastError || new Error("Failed to get response from any model, and no specific error was caught.");
2097
- });
2098
-
2099
- if (!isRootExecution) return execution;
1492
+ if (currentConfig.debug >= 1) console.log('');
1493
+ }
2100
1494
 
2101
- const result = await execution;
2102
- this._commitTemplateRenderContext(templateContext);
2103
- return result;
1495
+ _recordProviderResult(result) {
1496
+ this.lastRaw = result;
1497
+ if (this.config.max_history === 0) {
1498
+ this.messages = [];
1499
+ } else if (result.message) {
1500
+ if (result.assistantMessage) {
1501
+ this.messages.push(result.assistantMessage);
1502
+ } else if (result.signature) {
1503
+ this.messages.push({
1504
+ role: 'assistant',
1505
+ content: [{
1506
+ type: 'thinking',
1507
+ thinking: result.think ?? '',
1508
+ signature: result.signature
1509
+ }, {
1510
+ type: 'text',
1511
+ text: result.message
1512
+ }]
1513
+ });
1514
+ } else {
1515
+ this._addText(result.message, { role: 'assistant' });
1516
+ }
1517
+ }
2104
1518
  }
2105
1519
 
2106
- async processToolCalls(toolCalls) {
2107
- const result = []
1520
+ _logProviderFailure(error, currentModelKey, attempt, modelsToTry) {
1521
+ log.warn(`Model ${currentModelKey} failed (Attempt #${attempt + 1}/${modelsToTry.length}).`);
1522
+ if (error.message) log.warn(`Error: ${error.message}`);
1523
+ if (error.statusCode) log.warn(`Status Code: ${error.statusCode}`);
1524
+ if (error.details) log.warn(`Details:\n${ModelMix.formatJSON(error.details)}`);
2108
1525
 
2109
- for (const toolCall of toolCalls) {
2110
- // Handle different tool call formats more robustly
1526
+ if (attempt === modelsToTry.length - 1) {
1527
+ console.error(`All ${modelsToTry.length} model(s) failed. Throwing last error from ${currentModelKey}.`);
1528
+ throw error;
1529
+ }
1530
+ log.info(`-> Proceeding to next model: ${modelsToTry[attempt + 1].model.key}`);
1531
+ }
1532
+
1533
+ async _executeProviderChain({
1534
+ config,
1535
+ options,
1536
+ systemSuffix,
1537
+ outputMode,
1538
+ templateContext,
1539
+ pluginRequest,
1540
+ executionMetadata,
1541
+ pluginsApplied
1542
+ }) {
1543
+ const preparedMessages = pluginRequest
1544
+ ? pluginRequest.messages
1545
+ : await this.prepareMessages(templateContext);
1546
+ this._requirePreparedMessages(preparedMessages);
1547
+
1548
+ const finalConfig = pluginRequest ? pluginRequest.config : this._mergeRequestConfig(config);
1549
+ const modelsToTry = this.models.map((model, index) => ({ model, index }));
1550
+ if (finalConfig.roundRobin && this.models.length > 1) {
1551
+ this.models.push(this.models.shift());
1552
+ }
1553
+
1554
+ let lastError = null;
1555
+ for (let attempt = 0; attempt < modelsToTry.length; attempt++) {
1556
+ const { model: currentModel, index: originalIndex } = modelsToTry[attempt];
1557
+ const providerAttempt = this._createProviderAttempt({
1558
+ currentModel,
1559
+ preparedMessages,
1560
+ config,
1561
+ options,
1562
+ finalConfig,
1563
+ pluginRequest,
1564
+ systemSuffix,
1565
+ templateContext
1566
+ });
1567
+ this._logProviderAttempt({
1568
+ attempt,
1569
+ originalIndex,
1570
+ preparedMessages,
1571
+ ...providerAttempt
1572
+ });
1573
+
1574
+ try {
1575
+ const { result, elapsedMs } = await this._invokeProviderWithRetry(
1576
+ providerAttempt.provider,
1577
+ providerAttempt.currentOptions,
1578
+ providerAttempt.currentConfig,
1579
+ providerAttempt.resolvedModelKey
1580
+ );
1581
+ this._enrichResultTokens(result, providerAttempt.resolvedModelKey, elapsedMs);
1582
+
1583
+ if (result.toolCalls && result.toolCalls.length > 0) {
1584
+ return this._continueToolCalls(result, pluginRequest, {
1585
+ options,
1586
+ config,
1587
+ systemSuffix,
1588
+ outputMode,
1589
+ _templateContext: templateContext,
1590
+ _executionMetadata: executionMetadata,
1591
+ _pluginsApplied: pluginsApplied
1592
+ });
1593
+ }
1594
+
1595
+ this._logProviderSuccess(result, providerAttempt.currentConfig);
1596
+ this._recordProviderResult(result);
1597
+ return result;
1598
+ } catch (error) {
1599
+ lastError = error;
1600
+ this._logProviderFailure(error, currentModel.key, attempt, modelsToTry);
1601
+ }
1602
+ }
1603
+
1604
+ log.error('Fallback logic completed without success or throwing the final error.');
1605
+ throw lastError || new Error('Failed to get response from any model, and no specific error was caught.');
1606
+ }
1607
+
1608
+ async execute({
1609
+ config = {},
1610
+ options = {},
1611
+ systemSuffix = '',
1612
+ outputMode = 'raw',
1613
+ _templateContext = null,
1614
+ _pluginRequest = null,
1615
+ _executionMetadata = null,
1616
+ _pluginsApplied = false
1617
+ } = {}) {
1618
+ const isRootExecution = _templateContext === null;
1619
+ const templateContext = _templateContext || createTemplateRenderContext(() => this._choiceRandom());
1620
+
1621
+ if (!_pluginsApplied && this.plugins.length > 0) {
1622
+ return this._executePlugins({
1623
+ config,
1624
+ options,
1625
+ systemSuffix,
1626
+ outputMode,
1627
+ templateContext,
1628
+ executionMetadata: _executionMetadata,
1629
+ isRootExecution
1630
+ });
1631
+ }
1632
+
1633
+ if (!this.models || this.models.length === 0) {
1634
+ throw new Error('No models specified. Use methods like .gpt5(), .sonnet46() first.');
1635
+ }
1636
+
1637
+ const execution = this.limiter.schedule(() => this._executeProviderChain({
1638
+ config,
1639
+ options,
1640
+ systemSuffix,
1641
+ outputMode,
1642
+ templateContext,
1643
+ pluginRequest: _pluginRequest,
1644
+ executionMetadata: _executionMetadata,
1645
+ pluginsApplied: _pluginsApplied
1646
+ }));
1647
+
1648
+ if (!isRootExecution) return execution;
1649
+
1650
+ const result = await execution;
1651
+ this._commitTemplateRenderContext(templateContext);
1652
+ return result;
1653
+ }
1654
+ async processToolCalls(toolCalls) {
1655
+ const result = []
1656
+
1657
+ for (const toolCall of toolCalls) {
1658
+ // Handle different tool call formats more robustly
2111
1659
  let toolName, toolArgs, toolId;
2112
1660
 
2113
1661
  try {
@@ -2266,1915 +1814,33 @@ class ModelMix {
2266
1814
  }
2267
1815
  }
2268
1816
 
2269
- class MixCustom {
2270
- constructor({ config = {}, options = {}, headers = {} } = {}) {
2271
- this.config = this.getDefaultConfig(config);
2272
- this.options = this.getDefaultOptions(options);
2273
- this.headers = this.getDefaultHeaders(headers);
2274
- this.streamCallback = null; // Define streamCallback here
2275
- }
2276
-
2277
- getDefaultOptions(customOptions) {
2278
- return {
2279
- ...customOptions
2280
- };
2281
- }
2282
-
2283
- getDefaultConfig(customConfig) {
2284
- return {
2285
- url: '',
2286
- apiKey: '',
2287
- ...customConfig
2288
- };
2289
- }
2290
-
2291
- getDefaultHeaders(customHeaders) {
2292
- return {
2293
- 'accept': 'application/json',
2294
- 'content-type': 'application/json',
2295
- 'authorization': `Bearer ${this.config.apiKey}`,
2296
- ...customHeaders
2297
- };
2298
- }
2299
-
2300
- convertMessages(messages, config) {
2301
- return MixOpenAI.convertMessages(messages, config);
2302
- }
2303
-
2304
- sanitizeCacheOptions(options) {
2305
- delete options.cache_control;
2306
- delete options.prompt_cache_key;
2307
- delete options.prompt_cache_options;
2308
- delete options.prompt_cache_retention;
2309
- }
2310
-
2311
- static stripContentTypeHeader(headers = {}) {
2312
- return stripContentTypeHeader(headers);
2313
- }
2314
-
2315
- static createMultipartFormData({ fields = {}, files = [] } = {}) {
2316
- return createMultipartFormData({ fields, files });
2317
- }
2318
-
2319
- static buildRequestBodyAndHeaders(options, headers) {
2320
- return buildRequestBodyAndHeaders(options, headers);
2321
- }
2322
-
2323
- async create({ config = {}, options = {} } = {}) {
2324
- try {
2325
- this.sanitizeCacheOptions(options);
2326
- if (Array.isArray(options.messages)) {
2327
- options.messages = this.convertMessages(options.messages, config);
2328
- }
2329
-
2330
- const request = buildRequestBodyAndHeaders(options, this.headers);
2331
-
2332
- // debug level 4 (verbose): Full request details
2333
- if (config.debug >= 4) {
2334
- console.log('\n[REQUEST DETAILS]');
2335
-
2336
- console.log('\n[CONFIG]');
2337
- console.log(ModelMix.formatJSON(configForDebug(config)));
2338
-
2339
- console.log('\n[OPTIONS]');
2340
- console.log(ModelMix.formatJSON(request.options));
2341
- }
2342
-
2343
- if (options.stream) {
2344
- return this.processStream(await fetchStreamResponse(this.config.url, {
2345
- method: 'POST',
2346
- headers: request.headers,
2347
- body: request.body
2348
- }));
2349
- } else {
2350
- return this.processResponse(await fetchJsonResponse(this.config.url, {
2351
- method: 'POST',
2352
- headers: request.headers,
2353
- body: request.body
2354
- }));
2355
- }
2356
- } catch (error) {
2357
- throw this.handleError(error);
2358
- }
2359
- }
2360
-
2361
- handleError(error) {
2362
- let errorMessage = 'An error occurred in MixCustom';
2363
- let statusCode = null;
2364
- let errorDetails = null;
2365
-
2366
- if (error?.isHttpError || error?.response || typeof error?.statusCode === 'number') {
2367
- statusCode = error.statusCode ?? error.response?.status ?? null;
2368
- errorMessage = error.message || `Request to ${this.config.url} failed with status code ${statusCode}`;
2369
- errorDetails = error.details ?? error.response?.data ?? null;
2370
- } else if (error?.message) {
2371
- errorMessage = error.message;
2372
- }
2373
-
2374
- const formattedError = {
2375
- message: redactSecret(errorMessage, this.config.apiKey),
2376
- statusCode,
2377
- details: redactSecret(errorDetails, this.config.apiKey),
2378
- stack: redactSecret(error.stack, this.config.apiKey)
2379
- };
2380
-
2381
- return formattedError;
2382
- }
2383
-
2384
- processStream(response) {
2385
- return new Promise((resolve, reject) => {
2386
- let raw = [];
2387
- let message = '';
2388
- let buffer = '';
2389
-
2390
- response.data.on('data', chunk => {
2391
- buffer += chunk.toString();
2392
-
2393
- let boundary;
2394
- while ((boundary = buffer.indexOf('\n')) !== -1) {
2395
- const dataStr = buffer.slice(0, boundary).trim();
2396
- buffer = buffer.slice(boundary + 1);
2397
-
2398
- const firstBraceIndex = dataStr.indexOf('{');
2399
- if (dataStr === '[DONE]' || firstBraceIndex === -1) continue;
2400
-
2401
- const jsonStr = dataStr.slice(firstBraceIndex);
2402
- try {
2403
- const data = JSON.parse(jsonStr);
2404
- if (this.streamCallback) {
2405
- const delta = this.extractDelta(data);
2406
- message += delta;
2407
- this.streamCallback({ response: data, message, delta });
2408
- raw.push(data);
2409
- }
2410
- } catch (error) {
2411
- console.error('Error parsing JSON:', error);
2412
- }
2413
- }
2414
- });
2415
-
2416
- response.data.on('end', () => resolve({
2417
- response: raw,
2418
- message: message.trim(),
2419
- toolCalls: [],
2420
- think: null,
2421
- tokens: raw.length > 0 ? MixCustom.extractTokens(raw[raw.length - 1]) : { input: 0, output: 0, total: 0, cached: 0 }
2422
- }));
2423
- response.data.on('error', reject);
2424
- });
2425
- }
2426
-
2427
- extractDelta(data) {
2428
- return data.choices[0].delta.content;
2429
- }
2430
-
2431
- static extractMessage(data) {
2432
- const choice = data?.choices?.[0] || {};
2433
- const messageObj = choice.message || {};
2434
- const finishReason = choice.finish_reason;
2435
-
2436
- if (typeof messageObj.refusal === 'string' && messageObj.refusal.trim().length > 0) {
2437
- throw new Error(`OpenAI model refused to process this request: ${messageObj.refusal}`);
2438
- }
2439
-
2440
- if (finishReason === 'content_filter') {
2441
- throw new Error('OpenAI response was blocked by content_filter.');
2442
- }
2443
-
2444
- let message = '';
2445
- if (typeof messageObj.content === 'string') {
2446
- message = messageObj.content.trim();
2447
- } else if (Array.isArray(messageObj.content)) {
2448
- const refusalPart = messageObj.content.find(part => part?.type === 'refusal' || (typeof part?.refusal === 'string' && part.refusal.trim().length > 0));
2449
- if (refusalPart) {
2450
- const refusalText = typeof refusalPart.refusal === 'string' ? refusalPart.refusal : 'No refusal text provided.';
2451
- throw new Error(`OpenAI model refused to process this request: ${refusalText}`);
2452
- }
2453
- message = messageObj.content
2454
- .filter(part => typeof part?.text === 'string')
2455
- .map(part => part.text)
2456
- .join('')
2457
- .trim();
2458
- }
2459
-
2460
- const endTagIndex = message.indexOf('</think>');
2461
- if (message.startsWith('<think>') && endTagIndex !== -1) {
2462
- return message.substring(endTagIndex + 8).trim();
2463
- }
2464
- return message;
2465
- }
2466
-
2467
- static extractThink(data) {
2468
-
2469
- if (data.choices[0].message?.reasoning_content) {
2470
- return data.choices[0].message.reasoning_content;
2471
- } else if (data.choices[0].message?.reasoning) {
2472
- return data.choices[0].message.reasoning;
2473
- }
2474
-
2475
- const message = data.choices[0].message?.content?.trim() || '';
2476
- const endTagIndex = message.indexOf('</think>');
2477
- if (message.startsWith('<think>') && endTagIndex !== -1) {
2478
- return message.substring(7, endTagIndex).trim();
2479
- }
2480
- return null;
2481
- }
2482
-
2483
- static extractToolCalls(data) {
2484
- return data.choices[0].message?.tool_calls?.map(call => ({
2485
- id: call.id,
2486
- type: 'function',
2487
- function: {
2488
- name: call.function.name,
2489
- arguments: call.function.arguments
2490
- }
2491
- })) || []
2492
- }
2493
-
2494
- static extractTokens(data) {
2495
- // OpenAI/Groq/Together/Lambda/Cerebras/Fireworks format
2496
- if (data.usage) {
2497
- return ModelMix.normalizeTokenUsage({
2498
- input: data.usage.prompt_tokens || 0,
2499
- output: data.usage.completion_tokens || 0,
2500
- total: data.usage.total_tokens,
2501
- cached: ModelMix.extractCacheTokens(data.usage),
2502
- cacheWrite: ModelMix.extractCacheWriteTokens(data.usage)
2503
- });
2504
- }
2505
- return ModelMix.normalizeTokenUsage();
2506
- }
2507
-
2508
- processResponse(response) {
2509
- return {
2510
- message: MixCustom.extractMessage(response.data),
2511
- think: MixCustom.extractThink(response.data),
2512
- toolCalls: MixCustom.extractToolCalls(response.data),
2513
- tokens: MixCustom.extractTokens(response.data),
2514
- response: response.data
2515
- }
2516
- }
2517
-
2518
- getOptionsTools(tools) {
2519
- return MixOpenAI.getOptionsTools(tools);
2520
- }
2521
- }
2522
-
2523
- class MixOpenAI extends MixCustom {
2524
- sanitizeCacheOptions(options) {
2525
- delete options.cache_control;
2526
- delete options.prompt_cache_options;
2527
- }
2528
-
2529
- getDefaultConfig(customConfig) {
2530
-
2531
- if (!process.env.OPENAI_API_KEY) {
2532
- throw new Error('OpenAI API key not found. Please provide it in config or set OPENAI_API_KEY environment variable.');
2533
- }
2534
-
2535
- return super.getDefaultConfig({
2536
- url: 'https://api.openai.com/v1/chat/completions',
2537
- apiKey: process.env.OPENAI_API_KEY,
2538
- ...customConfig
2539
- });
2540
- }
2541
-
2542
- async create({ config = {}, options = {} } = {}) {
2543
-
2544
- // Remove max_tokens and temperature for o1/o3 models
2545
- if (options.model?.startsWith('o')) {
2546
- delete options.max_tokens;
2547
- delete options.temperature;
2548
- }
2549
-
2550
- // Use max_completion_tokens and remove temperature for GPT-5 models
2551
- if (options.model?.includes('gpt-5')) {
2552
- if (options.max_tokens) {
2553
- options.max_completion_tokens = options.max_tokens;
2554
- delete options.max_tokens;
2555
- }
2556
- delete options.temperature;
2557
- }
2558
-
2559
- return super.create({ config, options });
2560
- }
2561
-
2562
- static convertMessages(messages, config) {
2563
-
2564
- const content = config.system;
2565
- messages = [{ role: 'system', content }, ...messages || []];
2566
-
2567
- const results = []
2568
- for (const message of messages) {
2569
-
2570
- if (message.tool_calls) {
2571
- results.push({
2572
- role: 'assistant',
2573
- content: message.content ?? null,
2574
- ...(message.reasoning_content && { reasoning_content: message.reasoning_content }),
2575
- tool_calls: message.tool_calls
2576
- })
2577
- continue;
2578
- }
2579
-
2580
- if (message.role === 'tool') {
2581
- // Handle new format: tool_call_id directly on message
2582
- if (message.tool_call_id) {
2583
- results.push({
2584
- role: 'tool',
2585
- tool_call_id: message.tool_call_id,
2586
- content: message.content
2587
- });
2588
- }
2589
- // Handle old format: content is an array
2590
- else if (Array.isArray(message.content)) {
2591
- for (const content of message.content) {
2592
- results.push({
2593
- role: 'tool',
2594
- tool_call_id: content.tool_call_id,
2595
- content: content.content
2596
- })
2597
- }
2598
- }
2599
- continue;
2600
- }
2601
-
2602
- let convertedMessage = { ...message };
2603
- if (Array.isArray(message.content)) {
2604
- convertedMessage = {
2605
- ...message,
2606
- content: message.content.filter(content => content !== null && content !== undefined).map(content => {
2607
- if (content && content.type === 'image') {
2608
- const { media_type, data } = content.source;
2609
- return {
2610
- type: 'image_url',
2611
- image_url: {
2612
- url: `data:${media_type};base64,${data}`
2613
- }
2614
- };
2615
- }
2616
- return stripContentCacheMetadata(content);
2617
- })
2618
- };
2619
- }
2620
-
2621
- results.push(convertedMessage);
2622
- }
2623
-
2624
- return results;
2625
- }
2626
-
2627
- static getOptionsTools(tools) {
2628
- const options = {};
2629
- const toolsArray = [];
2630
- for (const tool in tools) {
2631
- for (const item of tools[tool]) {
2632
- toolsArray.push({
2633
- type: 'function',
2634
- function: {
2635
- name: item.name,
2636
- description: item.description,
2637
- parameters: item.inputSchema
2638
- }
2639
- });
2640
- }
2641
- }
2642
-
2643
- // Solo incluir tools si el array no está vacío
2644
- if (toolsArray.length > 0) {
2645
- options.tools = toolsArray;
2646
- // options.tool_choice = "auto";
2647
- }
2648
-
2649
- return options;
2650
- }
2651
- }
2652
-
2653
- class MixModeration extends MixCustom {
2654
- getOptionsTools() {
2655
- return {};
2656
- }
2657
- }
2658
-
2659
- class MixOpenAIResponses extends MixOpenAI {
2660
- async create({ config = {}, options = {} } = {}) {
2661
-
2662
- // Keep GPT/o-model option normalization behavior
2663
- if (options.model?.startsWith('o')) {
2664
- delete options.max_tokens;
2665
- delete options.temperature;
2666
- }
2667
- if (options.model?.includes('gpt-5')) {
2668
- if (options.max_tokens) {
2669
- options.max_completion_tokens = options.max_tokens;
2670
- delete options.max_tokens;
2671
- }
2672
- delete options.temperature;
2673
- }
2674
-
2675
- const responsesUrl = this.config.url.replace('/chat/completions', '/responses');
2676
- const request = MixOpenAIResponses.buildResponsesRequest(options, config);
2677
- const response = await fetchJsonResponse(responsesUrl, {
2678
- method: 'POST',
2679
- headers: this.headers,
2680
- body: JSON.stringify(request)
2681
- });
2682
-
2683
- return MixOpenAIResponses.processResponsesResponse(response);
2684
- }
2685
-
2686
- static buildResponsesRequest(options = {}, config = {}) {
2687
- const isGPT56 = typeof options.model === 'string' && options.model.startsWith('gpt-5.6');
2688
- const input = MixOpenAIResponses.messagesToResponsesInput(options.messages, {
2689
- translateNeutralCache: isGPT56
2690
- });
2691
- if (config.system) {
2692
- input.unshift({ role: 'developer', content: [{ type: 'input_text', text: config.system }] });
2693
- }
2694
- MixOpenAIResponses.validatePromptCaching(options, input);
2695
- const request = {
2696
- model: options.model,
2697
- input,
2698
- stream: false
2699
- };
2700
-
2701
- if (options.reasoning_effort) request.reasoning = { effort: options.reasoning_effort };
2702
- if (options.verbosity) request.text = { verbosity: options.verbosity };
2703
-
2704
- if (options.response_format) {
2705
- const rf = options.response_format;
2706
- let format;
2707
- if (rf.type === 'json_schema' && rf.json_schema) {
2708
- format = {
2709
- type: 'json_schema',
2710
- name: rf.json_schema.name || 'response',
2711
- strict: true,
2712
- schema: rf.json_schema.schema
2713
- };
2714
- } else if (rf.type) {
2715
- format = { type: rf.type };
2716
- }
2717
- if (format) {
2718
- request.text = { ...request.text, format };
2719
- }
2720
- }
2721
-
2722
- if (typeof options.max_completion_tokens === 'number') {
2723
- request.max_output_tokens = options.max_completion_tokens;
2724
- } else if (typeof options.max_tokens === 'number') {
2725
- request.max_output_tokens = options.max_tokens;
2726
- }
2727
-
2728
- if (typeof options.temperature === 'number') request.temperature = options.temperature;
2729
- if (typeof options.top_p === 'number') request.top_p = options.top_p;
2730
- if (typeof options.presence_penalty === 'number') request.presence_penalty = options.presence_penalty;
2731
- if (typeof options.frequency_penalty === 'number') request.frequency_penalty = options.frequency_penalty;
2732
- if (options.stop !== undefined) request.stop = options.stop;
2733
- if (typeof options.n === 'number') request.n = options.n;
2734
- if (options.logit_bias !== undefined) request.logit_bias = options.logit_bias;
2735
- if (options.user !== undefined) request.user = options.user;
2736
- if (options.prompt_cache_key !== undefined) request.prompt_cache_key = options.prompt_cache_key;
2737
- if (options.prompt_cache_retention !== undefined) request.prompt_cache_retention = options.prompt_cache_retention;
2738
- if (options.prompt_cache_options !== undefined) request.prompt_cache_options = options.prompt_cache_options;
2739
-
2740
- return request;
2741
- }
2742
-
2743
- static validatePromptCaching(options, input) {
2744
- const isGPT56 = typeof options.model === 'string' && options.model.startsWith('gpt-5.6');
2745
- const cacheOptions = options.prompt_cache_options;
2746
- const breakpoints = input.flatMap(message => Array.isArray(message.content)
2747
- ? message.content
2748
- .filter(block => block?.prompt_cache_breakpoint !== undefined)
2749
- .map(block => block.prompt_cache_breakpoint)
2750
- : []);
2751
-
2752
- if (isGPT56 && options.prompt_cache_retention !== undefined) {
2753
- throw new Error('GPT-5.6 does not support prompt_cache_retention; use prompt_cache_options.ttl instead.');
2754
- }
2755
- if (!isGPT56 && cacheOptions !== undefined) {
2756
- throw new Error('prompt_cache_options is only supported by GPT-5.6 models.');
2757
- }
2758
- if (!isGPT56 && breakpoints.length > 0) {
2759
- throw new Error('prompt_cache_breakpoint is only supported by GPT-5.6 models.');
2760
- }
2761
- if (cacheOptions !== undefined) {
2762
- if (!isPlainObject(cacheOptions)) {
2763
- throw new TypeError('prompt_cache_options must be a plain non-null object.');
2764
- }
2765
- if (cacheOptions.mode !== undefined
2766
- && cacheOptions.mode !== 'implicit'
2767
- && cacheOptions.mode !== 'explicit') {
2768
- throw new TypeError('prompt_cache_options.mode must be "implicit" or "explicit".');
2769
- }
2770
- if (cacheOptions.ttl !== undefined && cacheOptions.ttl !== '30m') {
2771
- throw new TypeError('prompt_cache_options.ttl must be "30m".');
2772
- }
2773
- }
2774
- for (const breakpoint of breakpoints) {
2775
- if (!isPlainObject(breakpoint)) {
2776
- throw new TypeError('prompt_cache_breakpoint must be a plain non-null object.');
2777
- }
2778
- if (breakpoint.mode !== 'explicit') {
2779
- throw new TypeError('prompt_cache_breakpoint mode must be "explicit".');
2780
- }
2781
- }
2782
- }
2783
-
2784
- static processResponsesResponse(response) {
2785
- const message = MixOpenAIResponses.extractResponsesMessage(response.data);
2786
- return {
2787
- message,
2788
- think: null,
2789
- toolCalls: [],
2790
- tokens: MixOpenAIResponses.extractResponsesTokens(response.data),
2791
- response: response.data
2792
- };
2793
- }
2794
-
2795
- static extractResponsesTokens(data) {
2796
- if (data.usage) {
2797
- return ModelMix.normalizeTokenUsage({
2798
- input: data.usage.input_tokens || 0,
2799
- output: data.usage.output_tokens || 0,
2800
- total: data.usage.total_tokens,
2801
- cached: ModelMix.extractCacheTokens(data.usage),
2802
- cacheWrite: ModelMix.extractCacheWriteTokens(data.usage)
2803
- });
2804
- }
2805
- return ModelMix.normalizeTokenUsage();
2806
- }
2807
-
2808
- static extractResponsesMessage(data) {
2809
- if (!Array.isArray(data.output)) return '';
2810
- return data.output
2811
- .filter(item => item.type === 'message')
2812
- .flatMap(item => Array.isArray(item.content) ? item.content : [])
2813
- .filter(content => content.type === 'output_text' && typeof content.text === 'string')
2814
- .map(content => content.text)
2815
- .join('\n')
2816
- .trim();
2817
- }
2818
-
2819
- static messagesToResponsesInput(messages = [], { translateNeutralCache = false } = {}) {
2820
- const mapped = [];
2821
-
2822
- for (const message of messages) {
2823
- if (!message || !message.role) continue;
2824
- if (message.tool_calls || message.role === 'tool') continue;
2825
-
2826
- const content = [];
2827
- const isAssistant = message.role === 'assistant';
2828
- const textType = isAssistant ? 'output_text' : 'input_text';
2829
- if (typeof message.content === 'string') {
2830
- if (message.content) content.push({ type: textType, text: message.content });
2831
- } else if (Array.isArray(message.content)) {
2832
- for (const item of message.content) {
2833
- if (!item || typeof item !== 'object') continue;
2834
- const neutralCache = item.cache !== undefined
2835
- ? normalizeContentCache(item.cache)
2836
- : undefined;
2837
- const promptCacheBreakpoint = item.prompt_cache_breakpoint !== undefined
2838
- ? item.prompt_cache_breakpoint
2839
- : (translateNeutralCache && neutralCache?.breakpoint
2840
- ? { mode: 'explicit' }
2841
- : undefined);
2842
- const breakpoint = !isAssistant && promptCacheBreakpoint !== undefined
2843
- ? { prompt_cache_breakpoint: promptCacheBreakpoint }
2844
- : {};
2845
-
2846
- if ((item.type === 'text' || item.type === 'input_text' || item.type === 'output_text')
2847
- && typeof item.text === 'string') {
2848
- content.push({ type: textType, text: item.text, ...breakpoint });
2849
- continue;
2850
- }
2851
- if (item.type === 'image' && item.source) {
2852
- let imageUrl;
2853
- if (item.source.type === 'base64') {
2854
- if (!item.source.media_type || typeof item.source.data !== 'string') {
2855
- throw new TypeError('Responses base64 images require source.media_type and string source.data.');
2856
- }
2857
- imageUrl = `data:${item.source.media_type};base64,${item.source.data}`;
2858
- } else if (item.source.type === 'url' && typeof item.source.data === 'string') {
2859
- imageUrl = item.source.data;
2860
- } else {
2861
- throw new TypeError('Responses images must be processed to base64 or use a URL source.');
2862
- }
2863
- content.push({ type: 'input_image', image_url: imageUrl, ...breakpoint });
2864
- continue;
2865
- }
2866
- if (item.type === 'image_url' && typeof item.image_url?.url === 'string') {
2867
- content.push({ type: 'input_image', image_url: item.image_url.url, ...breakpoint });
2868
- continue;
2869
- }
2870
- if (item.type === 'input_image' || item.type === 'input_file') {
2871
- content.push({
2872
- ...stripContentCacheMetadata(item),
2873
- ...breakpoint
2874
- });
2875
- }
2876
- }
2877
- }
2878
-
2879
- if (content.length === 0) continue;
2880
- mapped.push({
2881
- role: message.role,
2882
- content
2883
- });
2884
- }
2885
-
2886
- return mapped;
2887
- }
2888
- }
2889
-
2890
- class MixOpenAIModeration extends MixModeration {
2891
- getDefaultConfig(customConfig) {
2892
- const apiKey = customConfig.apiKey || process.env.OPENAI_API_KEY;
2893
- if (!apiKey) {
2894
- throw new Error('OpenAI API key not found. Please provide it in config or set OPENAI_API_KEY environment variable.');
2895
- }
2896
-
2897
- return super.getDefaultConfig({
2898
- url: 'https://api.openai.com/v1/moderations',
2899
- apiKey,
2900
- ...customConfig
2901
- });
2902
- }
2903
-
2904
- async create({ config = {}, options = {} } = {}) {
2905
- if (options.stream) {
2906
- throw new Error('Stream is not supported for OpenAI moderation');
2907
- }
2908
-
2909
- const input = MixOpenAIModeration.messagesToModerationInput(options.messages);
2910
- const response = await fetchJsonResponse(this.config.url, {
2911
- method: 'POST',
2912
- headers: this.headers,
2913
- body: JSON.stringify({ model: options.model, input })
2914
- });
2915
-
2916
- return {
2917
- moderation: response.data.results,
2918
- tokens: ModelMix.normalizeTokenUsage(),
2919
- response: response.data
2920
- };
2921
- }
2922
-
2923
- static messagesToModerationInput(messages = []) {
2924
- const input = [];
2925
-
2926
- for (const message of messages) {
2927
- if (typeof message.content === 'string') {
2928
- input.push({ type: 'text', text: message.content });
2929
- continue;
2930
- }
2931
- if (!Array.isArray(message.content)) continue;
2932
-
2933
- for (const content of message.content) {
2934
- if (content?.type === 'text') {
2935
- input.push({ type: 'text', text: content.text });
2936
- } else if (content?.type === 'image') {
2937
- const { media_type: mediaType, data } = content.source || {};
2938
- if (!mediaType || !data) {
2939
- throw new Error('OpenAI moderation images must be prepared as base64 data URLs');
2940
- }
2941
- input.push({
2942
- type: 'image_url',
2943
- image_url: { url: `data:${mediaType};base64,${data}` }
2944
- });
2945
- }
2946
- }
2947
- }
2948
-
2949
- return input;
2950
- }
2951
- }
2952
-
2953
- class ModerationMix extends ModelMix {
2954
- static new(setup = {}) {
2955
- return new ModerationMix(setup);
2956
- }
2957
-
2958
- new({ options = {}, config = {} } = {}) {
2959
- return new ModerationMix({
2960
- options: { ...this.options, ...options },
2961
- config: { ...this.config, ...config }
2962
- });
2963
- }
2964
-
2965
- attach(key, provider) {
2966
- if (!(provider instanceof MixModeration)) {
2967
- throw new Error('ModerationMix only accepts moderation providers.');
2968
- }
2969
- return super.attach(key, provider);
2970
- }
2971
-
2972
- openai({ options = {}, config = {} } = {}) {
2973
- return this.attach('omni-moderation-latest', new MixOpenAIModeration({ options, config }));
2974
- }
2975
-
2976
- async message() {
2977
- throw new Error('ModerationMix does not generate messages. Use raw() and read result.moderation.');
2978
- }
2979
-
2980
- async json() {
2981
- throw new Error('ModerationMix does not generate JSON. Use raw() and read result.moderation.');
2982
- }
2983
-
2984
- async block() {
2985
- throw new Error('ModerationMix does not generate blocks. Use raw() and read result.moderation.');
2986
- }
2987
-
2988
- async stream() {
2989
- throw new Error('ModerationMix does not support streaming. Use raw().');
2990
- }
2991
- }
2992
-
2993
- class MixOpenAIWebSocket extends MixOpenAIResponses {
2994
- getDefaultConfig(customConfig) {
2995
- return super.getDefaultConfig({
2996
- realtimeUrl: 'wss://api.openai.com/v1/realtime',
2997
- websocketTimeoutMs: 120000,
2998
- ...customConfig
2999
- });
3000
- }
3001
-
3002
- async create({ config = {}, options = {} } = {}) {
3003
- if (options.model?.startsWith('o')) {
3004
- delete options.max_tokens;
3005
- delete options.temperature;
3006
- }
3007
- if (options.model?.includes('gpt-5')) {
3008
- if (options.max_tokens) {
3009
- options.max_completion_tokens = options.max_tokens;
3010
- delete options.max_tokens;
3011
- }
3012
- delete options.temperature;
3013
- }
3014
-
3015
- const mergedConfig = { ...this.config, ...config };
3016
- const realtimeUrl = `${mergedConfig.realtimeUrl}?model=${encodeURIComponent(options.model)}`;
3017
- const timeoutMs = mergedConfig.websocketTimeoutMs || 120000;
3018
-
3019
- return await new Promise((resolve, reject) => {
3020
- const ws = new WebSocket(realtimeUrl, {
3021
- headers: {
3022
- authorization: `Bearer ${mergedConfig.apiKey}`
3023
- }
3024
- });
3025
-
3026
- const events = [];
3027
- let message = '';
3028
- let settled = false;
3029
- let finalResponse = null;
3030
-
3031
- const timeout = setTimeout(() => {
3032
- if (settled) return;
3033
- settled = true;
3034
- ws.close();
3035
- reject({
3036
- message: `Realtime WebSocket timed out after ${timeoutMs}ms`,
3037
- statusCode: null,
3038
- details: null
3039
- });
3040
- }, timeoutMs);
3041
-
3042
- const cleanUp = () => clearTimeout(timeout);
3043
-
3044
- ws.on('open', () => {
3045
- const session = {
3046
- type: 'realtime',
3047
- output_modalities: ['text']
3048
- };
3049
-
3050
- if (mergedConfig.system) session.instructions = mergedConfig.system;
3051
- if (Array.isArray(options.tools) && options.tools.length > 0) {
3052
- session.tools = options.tools;
3053
- }
3054
-
3055
- ws.send(JSON.stringify({ type: 'session.update', session }));
3056
-
3057
- const items = MixOpenAIWebSocket.messagesToConversationItems(options.messages);
3058
- for (const item of items) {
3059
- ws.send(JSON.stringify({
3060
- type: 'conversation.item.create',
3061
- item
3062
- }));
3063
- }
3064
-
3065
- const responseConfig = { output_modalities: ['text'] };
3066
- if (typeof options.max_completion_tokens === 'number') {
3067
- responseConfig.max_output_tokens = Math.min(options.max_completion_tokens, 4096);
3068
- } else if (typeof options.max_tokens === 'number') {
3069
- responseConfig.max_output_tokens = Math.min(options.max_tokens, 4096);
3070
- }
3071
- if (Array.isArray(options.tools) && options.tools.length > 0) responseConfig.tools = options.tools;
3072
-
3073
- ws.send(JSON.stringify({
3074
- type: 'response.create',
3075
- response: responseConfig
3076
- }));
3077
- });
3078
-
3079
- ws.on('message', raw => {
3080
- let event;
3081
- try {
3082
- event = JSON.parse(raw.toString());
3083
- } catch {
3084
- return;
3085
- }
3086
-
3087
- events.push(event);
3088
-
3089
- const isTextDeltaEvent = event.type === 'response.text.delta' || event.type === 'response.output_text.delta';
3090
- if (isTextDeltaEvent) {
3091
- const delta = MixOpenAIWebSocket.extractDelta(event);
3092
- if (delta) {
3093
- message += delta;
3094
- if (this.streamCallback) {
3095
- this.streamCallback({ response: event, message, delta });
3096
- }
3097
- }
3098
- return;
3099
- }
3100
-
3101
- if (event.type === 'response.done') {
3102
- finalResponse = event.response || null;
3103
- if (!message && finalResponse) {
3104
- message = MixOpenAIResponses.extractResponsesMessage(finalResponse);
3105
- }
3106
-
3107
- if (!settled) {
3108
- settled = true;
3109
- cleanUp();
3110
- ws.close();
3111
- resolve({
3112
- message: message.trim(),
3113
- think: null,
3114
- toolCalls: [],
3115
- tokens: MixOpenAIResponses.extractResponsesTokens(finalResponse || {}),
3116
- response: {
3117
- response: finalResponse,
3118
- events
3119
- }
3120
- });
3121
- }
3122
- return;
3123
- }
3124
-
3125
- if (event.type === 'error' && !settled) {
3126
- settled = true;
3127
- cleanUp();
3128
- ws.close();
3129
- reject({
3130
- message: event.error?.message || 'Realtime WebSocket error',
3131
- statusCode: null,
3132
- details: event.error || event
3133
- });
3134
- }
3135
- });
3136
-
3137
- ws.on('error', error => {
3138
- if (settled) return;
3139
- settled = true;
3140
- cleanUp();
3141
- reject({
3142
- message: error.message || 'Realtime WebSocket connection error',
3143
- statusCode: null,
3144
- details: null,
3145
- stack: error.stack
3146
- });
3147
- });
3148
-
3149
- ws.on('close', () => {
3150
- if (settled) return;
3151
- settled = true;
3152
- cleanUp();
3153
- reject({
3154
- message: 'Realtime WebSocket closed before response.done',
3155
- statusCode: null,
3156
- details: null
3157
- });
3158
- });
3159
- });
3160
- }
3161
-
3162
- static messagesToConversationItems(messages = []) {
3163
- const items = [];
3164
-
3165
- for (const message of messages) {
3166
- if (!message || !message.role) continue;
3167
- if (message.role === 'tool' || message.tool_calls) continue;
3168
-
3169
- const role = message.role === 'assistant' ? 'assistant' : (message.role === 'system' ? 'system' : 'user');
3170
- const content = [];
3171
-
3172
- if (typeof message.content === 'string') {
3173
- content.push({
3174
- type: role === 'assistant' ? 'text' : 'input_text',
3175
- text: message.content
3176
- });
3177
- } else if (Array.isArray(message.content)) {
3178
- for (const item of message.content) {
3179
- if (!item || item.type !== 'text' || typeof item.text !== 'string') continue;
3180
- content.push({
3181
- type: role === 'assistant' ? 'text' : 'input_text',
3182
- text: item.text
3183
- });
3184
- }
3185
- }
3186
-
3187
- if (content.length === 0) continue;
3188
- items.push({ type: 'message', role, content });
3189
- }
3190
-
3191
- return items;
3192
- }
3193
-
3194
- static extractDelta(event) {
3195
- if (typeof event.delta === 'string') return event.delta;
3196
- return '';
3197
- }
3198
- }
3199
-
3200
- class MixOpenRouter extends MixOpenAI {
3201
- getDefaultConfig(customConfig) {
3202
-
3203
- if (!process.env.OPENROUTER_API_KEY) {
3204
- throw new Error('OpenRouter API key not found. Please provide it in config or set OPENROUTER_API_KEY environment variable.');
3205
- }
3206
-
3207
- return MixCustom.prototype.getDefaultConfig.call(this, {
3208
- url: 'https://openrouter.ai/api/v1/chat/completions',
3209
- apiKey: process.env.OPENROUTER_API_KEY,
3210
- ...customConfig
3211
- });
3212
- }
3213
- }
3214
-
3215
- class MixKimi extends MixOpenAI {
3216
- getDefaultConfig(customConfig) {
3217
- if (!process.env.MOONSHOT_API_KEY) {
3218
- throw new Error('Moonshot API key not found. Please provide it in config or set MOONSHOT_API_KEY environment variable.');
3219
- }
3220
-
3221
- return MixCustom.prototype.getDefaultConfig.call(this, {
3222
- url: 'https://api.moonshot.ai/v1/chat/completions',
3223
- apiKey: process.env.MOONSHOT_API_KEY,
3224
- ...customConfig
3225
- });
3226
- }
3227
-
3228
- async create({ config = {}, options = {} } = {}) {
3229
- if (Object.hasOwn(options, 'max_tokens')) {
3230
- options.max_completion_tokens = options.max_tokens;
3231
- delete options.max_tokens;
3232
- }
3233
-
3234
- delete options.temperature;
3235
- delete options.top_p;
3236
- delete options.n;
3237
- delete options.presence_penalty;
3238
- delete options.frequency_penalty;
3239
-
3240
- return super.create({ config, options });
3241
- }
3242
-
3243
- extractDelta(data) {
3244
- return data?.choices?.[0]?.delta?.content || '';
3245
- }
3246
-
3247
- processResponse(response) {
3248
- return {
3249
- ...super.processResponse(response),
3250
- assistantMessage: response.data?.choices?.[0]?.message
3251
- };
3252
- }
3253
- }
3254
-
3255
- class MixAnthropic extends MixCustom {
3256
-
3257
- sanitizeCacheOptions(options) {
3258
- delete options.prompt_cache_key;
3259
- delete options.prompt_cache_options;
3260
- delete options.prompt_cache_retention;
3261
- }
3262
-
3263
- static validateCacheControl(cacheControl) {
3264
- if (!isPlainObject(cacheControl) || cacheControl.type !== 'ephemeral') {
3265
- throw new TypeError('Anthropic cache_control must have type "ephemeral".');
3266
- }
3267
- if (cacheControl.ttl !== undefined
3268
- && cacheControl.ttl !== '5m'
3269
- && cacheControl.ttl !== '1h') {
3270
- throw new TypeError('Anthropic cache_control.ttl must be "5m" or "1h".');
3271
- }
3272
- }
3273
-
3274
- /**
3275
- * Opus 4.7+ and Claude 5 family reject sampling params (temperature/top_p/top_k).
3276
- * See: https://platform.claude.com/docs/en/about-claude/models/migration-guide
3277
- */
3278
- static rejectsSamplingParams(model = '') {
3279
- const id = String(model).toLowerCase();
3280
- if (!id.includes('claude')) return false;
3281
- if (id.includes('mythos') || id.includes('fable')) return true;
3282
-
3283
- const opus = id.match(/claude-opus-(\d+)(?:-(\d+))?/);
3284
- if (opus) {
3285
- const major = Number(opus[1]);
3286
- const minor = opus[2] !== undefined ? Number(opus[2]) : 0;
3287
- return major > 4 || (major === 4 && minor >= 7);
3288
- }
3289
-
3290
- const sonnet = id.match(/claude-sonnet-(\d+)/);
3291
- if (sonnet) return Number(sonnet[1]) >= 5;
3292
-
3293
- return false;
3294
- }
3295
-
3296
- getDefaultConfig(customConfig) {
3297
-
3298
- if (!process.env.ANTHROPIC_API_KEY) {
3299
- throw new Error('Anthropic API key not found. Please provide it in config or set ANTHROPIC_API_KEY environment variable.');
3300
- }
3301
-
3302
- return super.getDefaultConfig({
3303
- url: 'https://api.anthropic.com/v1/messages',
3304
- apiKey: process.env.ANTHROPIC_API_KEY,
3305
- ...customConfig
3306
- });
3307
- }
3308
-
3309
- async create({ config = {}, options = {} } = {}) {
3310
-
3311
- delete options.response_format;
3312
-
3313
- if (MixAnthropic.rejectsSamplingParams(options.model)) {
3314
- delete options.temperature;
3315
- delete options.top_p;
3316
- delete options.top_k;
3317
- }
3318
-
3319
- const requestConfig = { ...config };
3320
- if (hasNeutralCacheBreakpoint(options.messages)) {
3321
- const contentCacheControl = options.cache_control ?? { type: 'ephemeral' };
3322
- MixAnthropic.validateCacheControl(contentCacheControl);
3323
- requestConfig._contentCacheControl = { ...contentCacheControl };
3324
- delete options.cache_control;
3325
- } else if (options.cache_control !== undefined) {
3326
- MixAnthropic.validateCacheControl(options.cache_control);
3327
- }
3328
-
3329
- options.system = config.system;
3330
-
3331
- try {
3332
- return await super.create({ config: requestConfig, options });
3333
- } catch (error) {
3334
- // Log the error details for debugging
3335
- if (error.response && error.response.data) {
3336
- log.error('Anthropic API Error:\n', error.response.data);
3337
- }
3338
- throw error;
3339
- }
3340
- }
3341
-
3342
- convertMessages(messages, config) {
3343
- return MixAnthropic.convertMessages(messages, config);
3344
- }
3345
-
3346
- static convertMessages(messages, config) {
3347
- // Filter out orphaned tool results for Anthropic
3348
- const filteredMessages = [];
3349
- for (let i = 0; i < messages.length; i++) {
3350
- if (messages[i].role === 'tool') {
3351
- // Preceding assistant may use OpenAI tool_calls or Anthropic tool_use blocks.
3352
- let foundToolCall = false;
3353
- for (let j = i - 1; j >= 0; j--) {
3354
- if (ModelMix.hasToolInteraction(messages[j]) && messages[j].role === 'assistant') {
3355
- foundToolCall = true;
3356
- break;
3357
- }
3358
- }
3359
- if (!foundToolCall) {
3360
- // Skip orphaned tool results
3361
- continue;
3362
- }
3363
- }
3364
- filteredMessages.push(messages[i]);
3365
- }
3366
-
3367
- return filteredMessages.map(message => {
3368
- if (message.role === 'tool') {
3369
- // Handle new format: tool_call_id directly on message
3370
- if (message.tool_call_id) {
3371
- return {
3372
- role: "user",
3373
- content: [{
3374
- type: "tool_result",
3375
- tool_use_id: message.tool_call_id,
3376
- content: message.content
3377
- }]
3378
- }
3379
- }
3380
- // Handle old format: content is an array
3381
- return {
3382
- role: "user",
3383
- content: message.content.map(content => ({
3384
- type: "tool_result",
3385
- tool_use_id: content.tool_call_id,
3386
- content: content.content
3387
- }))
3388
- }
3389
- }
3390
-
3391
- // Handle messages with tool_calls (assistant messages that call tools)
3392
- if (message.tool_calls) {
3393
- const content = message.tool_calls.map(call => ({
3394
- type: 'tool_use',
3395
- id: call.id,
3396
- name: call.function.name,
3397
- input: JSON.parse(call.function.arguments)
3398
- }));
3399
- return { role: 'assistant', content };
3400
- }
3401
-
3402
- // Handle content conversion for other messages
3403
- if (message.content && Array.isArray(message.content)) {
3404
- const content = message.content.filter(content => content !== null && content !== undefined).map(content => {
3405
- const neutralCache = content?.cache !== undefined
3406
- ? normalizeContentCache(content.cache)
3407
- : undefined;
3408
- if (neutralCache && content.cache_control !== undefined) {
3409
- throw new TypeError('Use either cache or cache_control on an Anthropic content block, not both.');
3410
- }
3411
- let converted = content;
3412
- if (content && content.type === 'function') {
3413
- converted = {
3414
- type: 'tool_use',
3415
- id: content.id,
3416
- name: content.function.name,
3417
- input: JSON.parse(content.function.arguments)
3418
- };
3419
- }
3420
- const sanitized = stripContentCacheMetadata(converted);
3421
- if (content.cache_control !== undefined) {
3422
- MixAnthropic.validateCacheControl(content.cache_control);
3423
- sanitized.cache_control = { ...content.cache_control };
3424
- } else if (neutralCache?.breakpoint) {
3425
- sanitized.cache_control = {
3426
- ...(config?._contentCacheControl || { type: 'ephemeral' })
3427
- };
3428
- }
3429
- return sanitized;
3430
- });
3431
- return { ...message, content };
3432
- }
3433
-
3434
- return { ...message };
3435
- });
3436
- }
3437
-
3438
- getDefaultHeaders(customHeaders) {
3439
- return super.getDefaultHeaders({
3440
- 'x-api-key': this.config.apiKey,
3441
- 'anthropic-version': '2023-06-01',
3442
- ...customHeaders
3443
- });
3444
- }
3445
-
3446
- extractDelta(data) {
3447
- if (data.delta && data.delta.text) return data.delta.text;
3448
- return '';
3449
- }
3450
-
3451
- static extractToolCalls(data) {
3452
-
3453
- return data.content.map(item => {
3454
- if (item.type === 'tool_use') {
3455
- return {
3456
- id: item.id,
3457
- type: 'function',
3458
- function: {
3459
- name: item.name,
3460
- arguments: JSON.stringify(item.input)
3461
- }
3462
- };
3463
- }
3464
- return null;
3465
- }).filter(item => item !== null);
3466
- }
3467
-
3468
- static extractMessage(data) {
3469
- const content = Array.isArray(data?.content) ? data.content : [];
3470
- const stopReason = data?.stop_reason;
3471
-
3472
- // Anthropic can return text in different positions depending on thinking/tool blocks.
3473
- const textBlock = content.find(block => typeof block?.text === 'string' && block.text.trim().length > 0);
3474
- if (textBlock) {
3475
- return textBlock.text;
3476
- }
3477
-
3478
- // A tool_use turn can legitimately contain no text blocks.
3479
- if (stopReason === 'tool_use') {
3480
- return '';
3481
- }
3482
-
3483
- // Empty/non-text content is often due to safety refusal or token limits.
3484
- const contentTypes = content.map(block => block?.type || 'unknown').join(', ') || 'none';
3485
-
3486
- if (stopReason === 'refusal') {
3487
- throw new Error('Anthropic refused to process this request (content policy). Try different wording or a fallback model.');
3488
- }
3489
- if (!content.length) {
3490
- throw new Error(`Anthropic returned empty content (stop_reason: ${stopReason ?? 'unknown'}).`);
3491
- }
3492
- throw new Error(`Anthropic content blocks are missing .text (stop_reason: ${stopReason ?? 'unknown'}, content_types: ${contentTypes}).`);
3493
- }
3494
-
3495
- static extractThinkingBlock(data) {
3496
- const content = Array.isArray(data?.content) ? data.content : [];
3497
- return content.find(block => block?.type === 'thinking') || null;
3498
- }
3499
-
3500
- static extractThink(data) {
3501
- const block = MixAnthropic.extractThinkingBlock(data);
3502
- // Preserve empty string: display "omitted" returns thinking: "" with a signature.
3503
- return typeof block?.thinking === 'string' ? block.thinking : null;
3504
- }
3505
-
3506
- static extractSignature(data) {
3507
- const block = MixAnthropic.extractThinkingBlock(data);
3508
- return typeof block?.signature === 'string' && block.signature
3509
- ? block.signature
3510
- : null;
3511
- }
3512
-
3513
- static extractTokens(data) {
3514
- // Anthropic format
3515
- if (data.usage) {
3516
- const cached = ModelMix.extractCacheTokens(data.usage);
3517
- const cacheWrite5m = data.usage.cache_creation?.ephemeral_5m_input_tokens ?? 0;
3518
- const cacheWrite1h = data.usage.cache_creation?.ephemeral_1h_input_tokens ?? 0;
3519
- const cacheWrite = Math.max(
3520
- ModelMix.extractCacheWriteTokens(data.usage),
3521
- cacheWrite5m + cacheWrite1h
3522
- );
3523
- const input = (data.usage.input_tokens || 0) + cached + cacheWrite;
3524
- const output = data.usage.output_tokens || 0;
3525
- return ModelMix.normalizeTokenUsage({
3526
- input,
3527
- output,
3528
- total: input + output,
3529
- cached,
3530
- cacheWrite,
3531
- cacheWrite5m,
3532
- cacheWrite1h
3533
- });
3534
- }
3535
- return ModelMix.normalizeTokenUsage();
3536
- }
3537
-
3538
- processResponse(response) {
3539
- const data = response.data;
3540
- return {
3541
- message: MixAnthropic.extractMessage(data),
3542
- think: MixAnthropic.extractThink(data),
3543
- toolCalls: MixAnthropic.extractToolCalls(data),
3544
- tokens: MixAnthropic.extractTokens(data),
3545
- response: data,
3546
- signature: MixAnthropic.extractSignature(data),
3547
- // Replay Anthropic content blocks verbatim (including empty thinking).
3548
- assistantMessage: Array.isArray(data?.content)
3549
- ? { role: 'assistant', content: data.content }
3550
- : undefined
3551
- }
3552
- }
3553
-
3554
- getOptionsTools(tools) {
3555
- return MixAnthropic.getOptionsTools(tools);
3556
- }
3557
-
3558
- static getOptionsTools(tools) {
3559
- const options = {};
3560
- const toolsArray = [];
3561
- for (const tool in tools) {
3562
- for (const item of tools[tool]) {
3563
- toolsArray.push({
3564
- name: item.name,
3565
- description: item.description,
3566
- input_schema: item.inputSchema
3567
- });
3568
- }
3569
- }
3570
-
3571
- // Solo incluir tools si el array no está vacío
3572
- if (toolsArray.length > 0) {
3573
- options.tools = toolsArray;
3574
- }
3575
-
3576
- return options;
3577
- }
3578
- }
3579
-
3580
- class MixMiniMax extends MixOpenAI {
3581
- getDefaultConfig(customConfig) {
3582
-
3583
- if (!process.env.MINIMAX_API_KEY) {
3584
- throw new Error('MiniMax API key not found. Please provide it in config or set MINIMAX_API_KEY environment variable.');
3585
- }
3586
-
3587
- return MixCustom.prototype.getDefaultConfig.call(this, {
3588
- url: 'https://api.minimax.io/v1/chat/completions',
3589
- apiKey: process.env.MINIMAX_API_KEY,
3590
- ...customConfig
3591
- });
3592
- }
3593
-
3594
- extractDelta(data) {
3595
- // MiniMax might send different formats during streaming
3596
- if (data.choices && data.choices[0] && data.choices[0].delta && data.choices[0].delta.content) {
3597
- return data.choices[0].delta.content;
3598
- }
3599
- return '';
3600
- }
3601
- }
3602
-
3603
- class MixMiMo extends MixOpenAI {
3604
- getDefaultConfig(customConfig) {
3605
- if (!process.env.MIMO_API_KEY) {
3606
- throw new Error('MiMo API key not found. Please provide it in config or set MIMO_API_KEY environment variable.');
3607
- }
3608
-
3609
- return MixCustom.prototype.getDefaultConfig.call(this, {
3610
- url: 'https://api.xiaomimimo.com/v1/chat/completions',
3611
- apiKey: process.env.MIMO_API_KEY,
3612
- ...customConfig
3613
- });
3614
- }
3615
-
3616
- getDefaultHeaders(customHeaders) {
3617
- return {
3618
- 'accept': 'application/json',
3619
- 'content-type': 'application/json',
3620
- 'api-key': this.config.apiKey,
3621
- ...customHeaders
3622
- };
3623
- }
3624
- }
3625
-
3626
- class MixPerplexity extends MixCustom {
3627
- getDefaultConfig(customConfig) {
3628
-
3629
- if (!process.env.PPLX_API_KEY) {
3630
- throw new Error('Perplexity API key not found. Please provide it in config or set PPLX_API_KEY environment variable.');
3631
- }
3632
-
3633
- return super.getDefaultConfig({
3634
- url: 'https://api.perplexity.ai/chat/completions',
3635
- apiKey: process.env.PPLX_API_KEY,
3636
- ...customConfig
3637
- });
3638
- }
3639
-
3640
- async create({ config = {}, options = {} } = {}) {
3641
-
3642
- if (config.schema) {
3643
- options.response_format = {
3644
- type: 'json_schema',
3645
- json_schema: { schema: config.schema }
3646
- };
3647
- }
3648
-
3649
- return super.create({ config, options });
3650
- }
3651
- }
3652
-
3653
- class MixOllama extends MixCustom {
3654
-
3655
- getDefaultConfig(customConfig) {
3656
- return super.getDefaultConfig({
3657
- url: 'http://localhost:11434/api/chat',
3658
- ...customConfig
3659
- });
3660
- }
3661
-
3662
- getDefaultOptions(customOptions) {
3663
- return {
3664
- options: customOptions,
3665
- };
3666
- }
3667
-
3668
- extractDelta(data) {
3669
- if (data.message && data.message.content) return data.message.content;
3670
- return '';
3671
- }
3672
-
3673
- extractMessage(data) {
3674
- return data.message.content.trim();
3675
- }
3676
-
3677
- convertMessages(messages, config) {
3678
- return MixOllama.convertMessages(messages, config);
3679
- }
3680
-
3681
- static convertMessages(messages, config) {
3682
- const content = config.system;
3683
- messages = [{ role: 'system', content }, ...messages || []];
3684
-
3685
- return messages.map(entry => {
3686
- let content = '';
3687
- let images = [];
3688
-
3689
- entry.content.forEach(item => {
3690
- if (item.type === 'text') {
3691
- content += item.text + ' ';
3692
- } else if (item.type === 'image') {
3693
- images.push(item.source.data);
3694
- }
3695
- });
3696
-
3697
- return {
3698
- role: entry.role,
3699
- content: content.trim(),
3700
- images: images
3701
- };
3702
- });
3703
- }
3704
- }
3705
-
3706
- class MixGrok extends MixOpenAI {
3707
- getDefaultConfig(customConfig) {
3708
-
3709
- if (!process.env.XAI_API_KEY) {
3710
- throw new Error('Grok API key not found. Please provide it in config or set XAI_API_KEY environment variable.');
3711
- }
3712
-
3713
- return super.getDefaultConfig({
3714
- url: 'https://api.x.ai/v1/chat/completions',
3715
- apiKey: process.env.XAI_API_KEY,
3716
- ...customConfig
3717
- });
3718
- }
3719
-
3720
- async create({ config = {}, options = {} } = {}) {
3721
- if (options.model === GROK420_REASONING || options.model === GROK420_NON_REASONING) {
3722
- delete options.reasoning_effort;
3723
- }
3724
- return super.create({ config, options });
3725
- }
3726
- }
3727
-
3728
- class MixLambda extends MixCustom {
3729
- getDefaultConfig(customConfig) {
3730
-
3731
- if (!process.env.LAMBDA_API_KEY) {
3732
- throw new Error('Lambda API key not found. Please provide it in config or set LAMBDA_API_KEY environment variable.');
3733
- }
3734
-
3735
- return super.getDefaultConfig({
3736
- url: 'https://api.lambda.ai/v1/chat/completions',
3737
- apiKey: process.env.LAMBDA_API_KEY,
3738
- ...customConfig
3739
- });
3740
- }
3741
- }
3742
-
3743
- class MixLMStudio extends MixCustom {
3744
- getDefaultConfig(customConfig) {
3745
- return super.getDefaultConfig({
3746
- url: 'http://localhost:1234/v1/chat/completions',
3747
- ...customConfig
3748
- });
3749
- }
3750
-
3751
- create({ config = {}, options = {} } = {}) {
3752
- if (config.schema) {
3753
- options.response_format = {
3754
- type: 'json_schema',
3755
- json_schema: { schema: config.schema }
3756
- };
3757
- }
3758
- return super.create({ config, options });
3759
- }
3760
-
3761
- static extractThink(data) {
3762
- const message = data.choices[0].message?.content?.trim() || '';
3763
-
3764
- // Check for LMStudio special tags
3765
- const startTag = '<|channel|>analysis<|message|>';
3766
- const endTag = '<|end|><|start|>assistant<|channel|>final<|message|>';
3767
-
3768
- const startIndex = message.indexOf(startTag);
3769
- const endIndex = message.indexOf(endTag);
3770
-
3771
- if (startIndex !== -1 && endIndex !== -1) {
3772
- // Extract content between the special tags
3773
- const thinkContent = message.substring(startIndex + startTag.length, endIndex).trim();
3774
- return thinkContent;
3775
- }
3776
-
3777
- // Fall back to default extraction method
3778
- return MixCustom.extractThink(data);
3779
- }
3780
-
3781
- static extractMessage(data) {
3782
- const message = data.choices[0].message?.content?.trim() || '';
3783
-
3784
- // Check for LMStudio special tags and extract final message
3785
- const endTag = '<|end|><|start|>assistant<|channel|>final<|message|>';
3786
- const endIndex = message.indexOf(endTag);
3787
-
3788
- if (endIndex !== -1) {
3789
- // Return only the content after the final message tag
3790
- return message.substring(endIndex + endTag.length).trim();
3791
- }
3792
-
3793
- // Fall back to default extraction method
3794
- return MixCustom.extractMessage(data);
3795
- }
3796
-
3797
- processResponse(response) {
3798
- return {
3799
- message: MixLMStudio.extractMessage(response.data),
3800
- think: MixLMStudio.extractThink(response.data),
3801
- toolCalls: MixCustom.extractToolCalls(response.data),
3802
- tokens: MixCustom.extractTokens(response.data),
3803
- response: response.data
3804
- };
3805
- }
3806
- }
3807
-
3808
- class MixGroq extends MixCustom {
3809
- getDefaultConfig(customConfig) {
3810
-
3811
- if (!process.env.GROQ_API_KEY) {
3812
- throw new Error('Groq API key not found. Please provide it in config or set GROQ_API_KEY environment variable.');
3813
- }
3814
-
3815
- return super.getDefaultConfig({
3816
- url: 'https://api.groq.com/openai/v1/chat/completions',
3817
- apiKey: process.env.GROQ_API_KEY,
3818
- ...customConfig
3819
- });
3820
- }
3821
- }
3822
-
3823
- class MixTogether extends MixCustom {
3824
- getDefaultConfig(customConfig) {
3825
-
3826
- if (!process.env.TOGETHER_API_KEY) {
3827
- throw new Error('Together API key not found. Please provide it in config or set TOGETHER_API_KEY environment variable.');
3828
- }
3829
-
3830
- return super.getDefaultConfig({
3831
- url: 'https://api.together.xyz/v1/chat/completions',
3832
- apiKey: process.env.TOGETHER_API_KEY,
3833
- ...customConfig
3834
- });
3835
- }
3836
-
3837
- getDefaultOptions(customOptions) {
3838
- return {
3839
- stop: ["<|eot_id|>", "<|eom_id|>"],
3840
- ...customOptions
3841
- };
3842
- }
3843
- }
3844
-
3845
- class MixCerebras extends MixCustom {
3846
- getDefaultConfig(customConfig) {
3847
-
3848
- if (!process.env.CEREBRAS_API_KEY) {
3849
- throw new Error('Together API key not found. Please provide it in config or set CEREBRAS_API_KEY environment variable.');
3850
- }
3851
-
3852
- return super.getDefaultConfig({
3853
- url: 'https://api.cerebras.ai/v1/chat/completions',
3854
- apiKey: process.env.CEREBRAS_API_KEY,
3855
- ...customConfig
3856
- });
3857
- }
3858
-
3859
- create({ config = {}, options = {} } = {}) {
3860
- delete options.response_format;
3861
- return super.create({ config, options });
3862
- }
3863
- }
3864
-
3865
- class MixFireworks extends MixCustom {
3866
- getDefaultConfig(customConfig) {
3867
-
3868
- if (!process.env.FIREWORKS_API_KEY) {
3869
- throw new Error('Fireworks API key not found. Please provide it in config or set FIREWORKS_API_KEY environment variable.');
3870
- }
3871
-
3872
- return super.getDefaultConfig({
3873
- url: 'https://api.fireworks.ai/inference/v1/chat/completions',
3874
- apiKey: process.env.FIREWORKS_API_KEY,
3875
- ...customConfig
3876
- });
3877
- }
3878
- }
3879
-
3880
- class MixNVIDIA extends MixCustom {
3881
- getDefaultConfig(customConfig) {
3882
-
3883
- if (!process.env.NVIDIA_API_KEY) {
3884
- throw new Error('NVIDIA API key not found. Please provide it in config or set NVIDIA_API_KEY environment variable.');
3885
- }
3886
-
3887
- return super.getDefaultConfig({
3888
- url: 'https://integrate.api.nvidia.com/v1/chat/completions',
3889
- apiKey: process.env.NVIDIA_API_KEY,
3890
- ...customConfig
3891
- });
3892
- }
3893
- }
3894
-
3895
- class MixGoogle extends MixCustom {
3896
- getDefaultConfig(customConfig) {
3897
- return super.getDefaultConfig({
3898
- url: 'https://generativelanguage.googleapis.com/v1beta/models',
3899
- apiKey: process.env.GEMINI_API_KEY,
3900
- ...customConfig
3901
- });
3902
- }
3903
-
3904
- getDefaultHeaders(customHeaders) {
3905
- return {
3906
- 'Content-Type': 'application/json',
3907
- ...customHeaders
3908
- };
3909
- }
3910
-
3911
- static convertMessages(messages, config) {
3912
- return messages.map(message => {
3913
-
3914
- // Handle assistant messages with tool_calls (content is null)
3915
- if (message.role === 'assistant' && message.tool_calls) {
3916
- return {
3917
- role: 'model',
3918
- parts: message.tool_calls.map(toolCall => {
3919
- const part = {
3920
- functionCall: {
3921
- name: toolCall.function.name,
3922
- args: JSON.parse(toolCall.function.arguments)
3923
- }
3924
- };
3925
- if (toolCall.thought_signature) {
3926
- part.thoughtSignature = toolCall.thought_signature;
3927
- }
3928
- return part;
3929
- })
3930
- }
3931
- }
3932
-
3933
- // Handle new tool result format: tool_call_id and name directly on message
3934
- if (message.role === 'tool' && message.name) {
3935
- return {
3936
- role: 'user',
3937
- parts: [{
3938
- functionResponse: {
3939
- name: message.name,
3940
- response: {
3941
- output: message.content,
3942
- },
3943
- }
3944
- }]
3945
- }
3946
- }
3947
-
3948
- if (!Array.isArray(message.content)) return message;
3949
- const role = (message.role === 'assistant' || message.role === 'tool') ? 'model' : 'user'
3950
-
3951
- if (message.role === 'tool') {
3952
- // Handle old format: content is an array of {name, content}
3953
- return {
3954
- role,
3955
- parts: message.content.map(content => ({
3956
- functionResponse: {
3957
- name: content.name,
3958
- response: {
3959
- output: content.content,
3960
- },
3961
- }
3962
- }))
3963
- }
3964
- }
3965
-
3966
- return {
3967
- role,
3968
- parts: message.content.map(content => {
3969
- if (content.type === 'text') {
3970
- return { text: content.text };
3971
- }
3972
-
3973
- if (content.type === 'image') {
3974
- return {
3975
- inline_data: {
3976
- mime_type: content.source.media_type,
3977
- data: content.source.data
3978
- }
3979
- }
3980
- }
3981
-
3982
- if (content.type === 'function') {
3983
- return {
3984
- functionCall: {
3985
- name: content.function.name,
3986
- args: JSON.parse(content.function.arguments)
3987
- }
3988
- }
3989
- }
3990
-
3991
- return content;
3992
- })
3993
- }
3994
- });
3995
-
3996
- // Merge consecutive user messages containing only functionResponse parts
3997
- // Google requires all function responses for a turn in a single message
3998
- return converted.reduce((acc, msg) => {
3999
- if (acc.length > 0) {
4000
- const prev = acc[acc.length - 1];
4001
- if (prev.role === 'user' && msg.role === 'user' &&
4002
- prev.parts.every(p => p.functionResponse) &&
4003
- msg.parts.every(p => p.functionResponse)) {
4004
- prev.parts.push(...msg.parts);
4005
- return acc;
4006
- }
4007
- }
4008
- acc.push(msg);
4009
- return acc;
4010
- }, []);
4011
- }
4012
-
4013
- async create({ config = {}, options = {} } = {}) {
4014
- if (!this.config.apiKey) {
4015
- throw new Error('Gemini API key not found. Please provide it in config or set GEMINI_API_KEY environment variable.');
4016
- }
4017
-
4018
- const generateContentApi = options.stream ? 'streamGenerateContent' : 'generateContent';
4019
-
4020
- const fullUrl = `${this.config.url}/${options.model}:${generateContentApi}?key=${this.config.apiKey}`;
4021
-
4022
-
4023
- const content = config.system;
4024
- const systemInstruction = { parts: [{ text: content }] };
4025
-
4026
- options.messages = MixGoogle.convertMessages(options.messages);
4027
-
4028
- const generationConfig = {
4029
- maxOutputTokens: options.max_tokens,
4030
- }
4031
-
4032
- if (options.top_p) {
4033
- generationConfig.topP = options.top_p;
4034
- }
4035
-
4036
- // Thinking / effort (from unified config.effort or native options)
4037
- if (options.thinkingConfig) {
4038
- generationConfig.thinkingConfig = options.thinkingConfig;
4039
- } else if (options.thinkingLevel != null || options.thinkingBudget != null) {
4040
- generationConfig.thinkingConfig = {};
4041
- if (options.thinkingLevel != null) {
4042
- generationConfig.thinkingConfig.thinkingLevel = options.thinkingLevel;
4043
- }
4044
- if (options.thinkingBudget != null) {
4045
- generationConfig.thinkingConfig.thinkingBudget = options.thinkingBudget;
4046
- }
4047
- }
4048
-
4049
- // Gemini does not support responseMimeType when function calling is used
4050
- const hasTools = options.tools && options.tools.length > 0 &&
4051
- options.tools.some(t => t.functionDeclarations && t.functionDeclarations.length > 0);
4052
-
4053
- if (!hasTools) {
4054
- generationConfig.responseMimeType = "text/plain";
4055
- }
4056
-
4057
- const payload = {
4058
- generationConfig,
4059
- systemInstruction,
4060
- contents: options.messages,
4061
- tools: options.tools
4062
- };
4063
-
4064
- try {
4065
- // debug level 4 (verbose): Full request details
4066
- if (config.debug >= 4) {
4067
- console.log('\n[REQUEST DETAILS - GOOGLE]');
4068
-
4069
- console.log('\n[CONFIG]');
4070
- console.log(ModelMix.formatJSON(configForDebug(config)));
4071
-
4072
- console.log('\n[PAYLOAD]');
4073
- console.log(ModelMix.formatJSON(payload));
4074
- }
4075
-
4076
- if (options.stream) {
4077
- throw new Error('Stream is not supported for Gemini');
4078
- } else {
4079
- return this.processResponse(await fetchJsonResponse(fullUrl, {
4080
- method: 'POST',
4081
- headers: this.headers,
4082
- body: JSON.stringify(payload)
4083
- }));
4084
- }
4085
- } catch (error) {
4086
- throw this.handleError(error);
4087
- }
4088
- }
4089
-
4090
- processResponse(response) {
4091
- return {
4092
- message: MixGoogle.extractMessage(response.data),
4093
- think: null,
4094
- toolCalls: MixGoogle.extractToolCalls(response.data),
4095
- tokens: MixGoogle.extractTokens(response.data),
4096
- response: response.data
4097
- }
4098
- }
4099
-
4100
- static extractToolCalls(data) {
4101
- return data.candidates?.[0]?.content?.parts?.map(part => {
4102
- if (part.functionCall) {
4103
- return {
4104
- id: part.functionCall.id,
4105
- type: 'function',
4106
- function: {
4107
- name: part.functionCall.name,
4108
- arguments: JSON.stringify(part.functionCall.args)
4109
- },
4110
- thought_signature: part.thoughtSignature || ""
4111
- };
4112
- }
4113
- return null;
4114
- }).filter(item => item !== null) || [];
4115
- }
4116
-
4117
- static extractMessage(data) {
4118
- return data.candidates?.[0]?.content?.parts?.[0]?.text;
4119
- }
4120
-
4121
- static extractTokens(data) {
4122
- // Google Gemini format
4123
- if (data.usageMetadata) {
4124
- return ModelMix.normalizeTokenUsage({
4125
- input: data.usageMetadata.promptTokenCount || 0,
4126
- output: data.usageMetadata.candidatesTokenCount || 0,
4127
- thinking: data.usageMetadata.thoughtsTokenCount || 0,
4128
- total: data.usageMetadata.totalTokenCount,
4129
- cached: ModelMix.extractCacheTokens(data.usageMetadata),
4130
- cacheWrite: ModelMix.extractCacheWriteTokens(data.usageMetadata)
4131
- });
4132
- }
4133
- return ModelMix.normalizeTokenUsage();
4134
- }
4135
-
4136
- static stripUnsupportedSchemaProps(schema) {
4137
- if (!schema || typeof schema !== 'object') return schema;
4138
- const cleaned = { ...schema };
4139
- delete cleaned.default;
4140
- if (cleaned.properties) {
4141
- cleaned.properties = Object.fromEntries(
4142
- Object.entries(cleaned.properties).map(([key, value]) => [key, MixGoogle.stripUnsupportedSchemaProps(value)])
4143
- );
4144
- }
4145
- if (cleaned.items) {
4146
- cleaned.items = MixGoogle.stripUnsupportedSchemaProps(cleaned.items);
4147
- }
4148
- return cleaned;
4149
- }
4150
-
4151
- static getOptionsTools(tools) {
4152
- const functionDeclarations = [];
4153
- for (const tool in tools) {
4154
- for (const item of tools[tool]) {
4155
- functionDeclarations.push({
4156
- name: item.name,
4157
- description: item.description,
4158
- parameters: MixGoogle.stripUnsupportedSchemaProps(item.inputSchema)
4159
- });
4160
- }
4161
- }
4162
-
4163
- const options = {};
4164
-
4165
- // Solo incluir tools si el array no está vacío
4166
- if (functionDeclarations.length > 0) {
4167
- options.tools = [{
4168
- functionDeclarations
4169
- }];
4170
- }
4171
-
4172
- return options;
4173
- }
4174
-
4175
- getOptionsTools(tools) {
4176
- return MixGoogle.getOptionsTools(tools);
4177
- }
4178
- }
1817
+ ({
1818
+ MixCustom,
1819
+ MixOpenAI,
1820
+ MixModeration,
1821
+ MixOpenAIResponses,
1822
+ MixOpenAIModeration,
1823
+ MixOpenAIWebSocket,
1824
+ MixOpenRouter,
1825
+ MixKimi,
1826
+ MixAnthropic,
1827
+ MixMiniMax,
1828
+ MixMiMo,
1829
+ MixPerplexity,
1830
+ MixOllama,
1831
+ MixGrok,
1832
+ MixLambda,
1833
+ MixLMStudio,
1834
+ MixGroq,
1835
+ MixTogether,
1836
+ MixCerebras,
1837
+ MixFireworks,
1838
+ MixNVIDIA,
1839
+ MixGoogle,
1840
+ ModerationMix
1841
+ } = require('./lib/providers')({
1842
+ ModelMix,
1843
+ log
1844
+ }));
4179
1845
 
4180
1846
  module.exports = { MixCustom, ModelMix, ModerationMix, MixModeration, MixAnthropic, MixKimi, MixMiniMax, MixMiMo, MixOpenAI, MixOpenAIResponses, MixOpenAIModeration, MixOpenAIWebSocket, MixOpenRouter, MixPerplexity, MixOllama, MixLMStudio, MixGroq, MixTogether, MixGrok, MixCerebras, MixGoogle, MixFireworks, MixNVIDIA, normalizeEffort, applyUnifiedEffort, resolveProviderFamily };