modelmix 4.7.4 → 5.0.1

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
@@ -1,4 +1,5 @@
1
1
  const fs = require('fs');
2
+ const ejs = require('ejs');
2
3
  const fileType = require('file-type');
3
4
  const detectFileTypeFromBuffer = fileType.fileTypeFromBuffer || fileType.fromBuffer;
4
5
  const { inspect } = require('util');
@@ -24,7 +25,9 @@ const {
24
25
  normalizeEffort,
25
26
  applyUnifiedEffort,
26
27
  resolveProviderFamily,
27
- resolveGrok420ModelKey
28
+ resolveGrok420ModelKey,
29
+ GROK420_REASONING,
30
+ GROK420_NON_REASONING
28
31
  } = require('./effort');
29
32
 
30
33
  const DEFAULT_RETRYABLE_STATUS_CODES = [408, 425, 429, 500, 502, 503, 504, 529];
@@ -37,102 +40,319 @@ function sleep(ms) {
37
40
  return new Promise(resolve => setTimeout(resolve, ms));
38
41
  }
39
42
 
40
- // Pricing per 1M tokens: [input, output] in USD
43
+ function isPlainObject(value) {
44
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
45
+ const prototype = Object.getPrototypeOf(value);
46
+ return prototype === Object.prototype || prototype === null;
47
+ }
48
+
49
+ function normalizeContentCache(cache) {
50
+ if (cache !== undefined) {
51
+ if (!isPlainObject(cache) || cache.breakpoint !== true) {
52
+ throw new TypeError('cache must be { breakpoint: true }.');
53
+ }
54
+ return { breakpoint: true };
55
+ }
56
+ return undefined;
57
+ }
58
+
59
+ function stripContentCacheMetadata(content) {
60
+ if (!content || typeof content !== 'object') return content;
61
+ const sanitized = { ...content };
62
+ delete sanitized.cache;
63
+ delete sanitized.cache_control;
64
+ delete sanitized.prompt_cache_breakpoint;
65
+ return sanitized;
66
+ }
67
+
68
+ function hasNeutralCacheBreakpoint(messages = []) {
69
+ return messages.some(message => Array.isArray(message?.content)
70
+ && message.content.some(block => block?.cache?.breakpoint === true));
71
+ }
72
+
73
+ function validateTemplateData(value) {
74
+ if (!isPlainObject(value)) {
75
+ throw new TypeError('Template data must be a plain non-null object.');
76
+ }
77
+ if (Object.prototype.hasOwnProperty.call(value, '$mix')) {
78
+ throw new TypeError('Template data key "$mix" is reserved.');
79
+ }
80
+ }
81
+
82
+ function validateTemplateDataKey(key) {
83
+ if (typeof key !== 'string' || key.length === 0) {
84
+ throw new TypeError('Template data key must be a non-empty string.');
85
+ }
86
+ if (key === '$mix') {
87
+ throw new TypeError('Template data key "$mix" is reserved.');
88
+ }
89
+ }
90
+
91
+ function templateLocation({ filename, label }, lineNumber) {
92
+ return `${filename || label} at line ${lineNumber}`;
93
+ }
94
+
95
+ function preprocessChoiceDirectives(source, { filename = null, label = 'template' } = {}) {
96
+ const parts = source.split(/(\r\n|\n|\r)/);
97
+ const blocks = [];
98
+
99
+ for (let index = 0; index < parts.length; index += 2) {
100
+ const line = parts[index];
101
+ const trimmed = line.trim();
102
+ const lineNumber = (index / 2) + 1;
103
+ const location = templateLocation({ filename, label }, lineNumber);
104
+ const newline = parts[index + 1] || '';
105
+
106
+ if (/^<%\s*choice\s*%>$/.test(trimmed)) {
107
+ const parent = blocks[blocks.length - 1];
108
+ if (parent && parent.optionCount === 0) {
109
+ throw new Error(`A nested choice must be inside an option (${location}).`);
110
+ }
111
+ blocks.push({ lineNumber, optionCount: 0, weighted: null });
112
+ parts[index] = '<% $mix.choice(option => { -%>';
113
+ continue;
114
+ }
115
+
116
+ const optionMatch = trimmed.match(/^<%\s*option(?:\s+(.+?))?\s*%>$/);
117
+ if (optionMatch) {
118
+ const block = blocks[blocks.length - 1];
119
+ if (!block) {
120
+ throw new Error(`Option directive must be inside a choice (${location}).`);
121
+ }
122
+
123
+ const weightText = optionMatch[1];
124
+ const weighted = weightText !== undefined;
125
+ if (block.weighted !== null && block.weighted !== weighted) {
126
+ throw new Error(`Choice options must either all have weights or all omit them (${location}).`);
127
+ }
128
+
129
+ let argument = '';
130
+ if (weighted) {
131
+ const weight = Number(weightText);
132
+ if (!Number.isFinite(weight) || weight <= 0) {
133
+ throw new Error(`Choice weight must be a positive finite number (${location}).`);
134
+ }
135
+ argument = `${weight}, `;
136
+ }
137
+
138
+ block.weighted = weighted;
139
+ parts[index] = `<% ${block.optionCount > 0 ? '}); ' : ''}option(${argument}() => { -%>`;
140
+ block.optionCount += 1;
141
+ continue;
142
+ }
143
+
144
+ if (/^<%\s*\/choice\s*%>$/.test(trimmed)) {
145
+ const block = blocks.pop();
146
+ if (!block) {
147
+ throw new Error(`Closing choice directive has no matching opening directive (${location}).`);
148
+ }
149
+ if (block.optionCount === 0) {
150
+ throw new Error(`Choice must contain at least one option (${location}).`);
151
+ }
152
+ parts[index] = '<% }); }); -%>';
153
+ continue;
154
+ }
155
+
156
+ if (/^<%\s*(?:choice|option|\/choice)(?:\s|%>)/.test(trimmed)) {
157
+ throw new Error(`Invalid choice directive (${location}).`);
158
+ }
159
+
160
+ const block = blocks[blocks.length - 1];
161
+ if (block && block.optionCount === 0) {
162
+ if (trimmed) {
163
+ throw new Error(`Choice content must be inside an option (${location}).`);
164
+ }
165
+ parts[index] = '<%# -%>';
166
+ }
167
+
168
+ if (newline) parts[index + 1] = newline;
169
+ }
170
+
171
+ if (blocks.length > 0) {
172
+ const block = blocks[blocks.length - 1];
173
+ throw new Error(`Unclosed choice directive (${templateLocation({ filename, label }, block.lineNumber)}).`);
174
+ }
175
+
176
+ return parts.join('');
177
+ }
178
+
179
+ function createTemplateRenderContext(random = Math.random) {
180
+ const choice = defineOptions => {
181
+ if (typeof defineOptions !== 'function') {
182
+ throw new TypeError('$mix.choice expects an option definition callback.');
183
+ }
184
+
185
+ const options = [];
186
+ let weighted = null;
187
+ const option = (weightOrRender, renderOption) => {
188
+ const hasWeight = renderOption !== undefined;
189
+ const weight = hasWeight ? weightOrRender : 1;
190
+ const render = hasWeight ? renderOption : weightOrRender;
191
+
192
+ if (weighted !== null && weighted !== hasWeight) {
193
+ throw new TypeError('$mix.choice options cannot mix weighted and unweighted forms.');
194
+ }
195
+ if (!Number.isFinite(weight) || weight <= 0) {
196
+ throw new TypeError('$mix.choice weights must be positive finite numbers.');
197
+ }
198
+ if (typeof render !== 'function') {
199
+ throw new TypeError('$mix.choice options require a render callback.');
200
+ }
201
+
202
+ weighted = hasWeight;
203
+ options.push({ weight, render });
204
+ };
205
+
206
+ defineOptions(option);
207
+ if (options.length === 0) {
208
+ throw new Error('$mix.choice requires at least one option.');
209
+ }
210
+
211
+ const totalWeight = options.reduce((sum, current) => sum + current.weight, 0);
212
+ if (!Number.isFinite(totalWeight)) {
213
+ throw new TypeError('$mix.choice total weight must be finite.');
214
+ }
215
+
216
+ let target = random() * totalWeight;
217
+ for (const current of options) {
218
+ target -= current.weight;
219
+ if (target < 0) return current.render();
220
+ }
221
+ return options[options.length - 1].render();
222
+ };
223
+
224
+ return {
225
+ helpers: Object.freeze({ choice }),
226
+ renderedTemplateData: new Map(),
227
+ renderedMessages: new Map(),
228
+ renderedSystems: new Map()
229
+ };
230
+ }
231
+
232
+ function configForDebug(config) {
233
+ const safeConfig = { ...config };
234
+ delete safeConfig.apiKey;
235
+ delete safeConfig.debug;
236
+ return safeConfig;
237
+ }
238
+
239
+ function redactSecret(value, secret, seen = new WeakSet()) {
240
+ if (!secret) return value;
241
+ if (typeof value === 'string') return value.split(secret).join('[REDACTED]');
242
+ if (!value || typeof value !== 'object') return value;
243
+ if (seen.has(value)) return '[Circular]';
244
+
245
+ seen.add(value);
246
+ if (Array.isArray(value)) {
247
+ return value.map(item => redactSecret(item, secret, seen));
248
+ }
249
+ return Object.fromEntries(
250
+ Object.entries(value).map(([key, item]) => [key, redactSecret(item, secret, seen)])
251
+ );
252
+ }
253
+
254
+ // Pricing per 1M tokens in USD
41
255
  // Based on provider pricing pages linked in README
256
+ const GPT56_LONG_CONTEXT_PRICING = Object.freeze({
257
+ inputThreshold: 272_000,
258
+ inputMultiplier: 2,
259
+ outputMultiplier: 1.5
260
+ });
261
+
42
262
  const MODEL_PRICING = {
43
263
  // OpenAI
44
- 'gpt-realtime-mini': [0.60, 2.40],
45
- 'gpt-realtime': [4.00, 16.00],
46
- 'gpt-5.6-sol': [5.00, 30.00],
47
- 'gpt-5.6-terra': [2.00, 12.00],
48
- 'gpt-5.6-luna': [0.20, 1.20],
49
- 'gpt-5.5-pro': [30.00, 180.00],
50
- 'gpt-5.5': [5.00, 30.00],
51
- 'gpt-5.4': [2.50, 15.00],
52
- 'gpt-5.4-pro': [30, 180.00],
53
- 'gpt-5.4-mini': [0.75, 4.50],
54
- 'gpt-5.4-nano': [0.20, 1.25],
55
- 'gpt-5.3-codex': [1.75, 14.00],
56
- 'gpt-5.2': [1.75, 14.00],
57
- 'gpt-5.2-chat-latest': [1.75, 14.00],
58
- 'gpt-5.1': [1.25, 10.00],
59
- 'gpt-5': [1.25, 10.00],
60
- 'gpt-5-mini': [0.25, 2.00],
61
- 'gpt-5-nano': [0.05, 0.40],
62
- 'gpt-4.1': [2.00, 8.00],
63
- 'gpt-4.1-mini': [0.40, 1.60],
64
- 'gpt-4.1-nano': [0.10, 0.40],
264
+ 'gpt-realtime-mini': { input: 0.60, cachedInput: 0.06, output: 2.40 },
265
+ 'gpt-realtime': { input: 4.00, cachedInput: 0.40, output: 16.00 },
266
+ 'gpt-5.6-sol': { input: 5.00, cachedInput: 0.50, cacheWrite: 6.25, output: 30.00, longContext: GPT56_LONG_CONTEXT_PRICING },
267
+ 'gpt-5.6-terra': { input: 2.00, cachedInput: 0.20, cacheWrite: 2.50, output: 12.00, longContext: GPT56_LONG_CONTEXT_PRICING },
268
+ 'gpt-5.6-luna': { input: 0.20, cachedInput: 0.02, cacheWrite: 0.25, output: 1.20, longContext: GPT56_LONG_CONTEXT_PRICING },
269
+ 'gpt-5.5-pro': { input: 30.00, output: 180.00 },
270
+ 'gpt-5.5': { input: 5.00, cachedInput: 0.50, output: 30.00 },
271
+ 'gpt-5.4': { input: 2.50, cachedInput: 0.25, output: 15.00 },
272
+ 'gpt-5.4-pro': { input: 30.00, output: 180.00 },
273
+ 'gpt-5.4-mini': { input: 0.75, cachedInput: 0.075, output: 4.50 },
274
+ 'gpt-5.4-nano': { input: 0.20, cachedInput: 0.02, output: 1.25 },
275
+ 'gpt-5.3-codex': { input: 1.75, cachedInput: 0.175, output: 14.00 },
276
+ 'gpt-5.2': { input: 1.75, cachedInput: 0.175, output: 14.00 },
277
+ 'gpt-5.2-chat-latest': { input: 1.75, cachedInput: 0.175, output: 14.00 },
278
+ 'gpt-5.1': { input: 1.25, cachedInput: 0.125, output: 10.00 },
279
+ 'gpt-5': { input: 1.25, cachedInput: 0.125, output: 10.00 },
280
+ 'gpt-5-mini': { input: 0.25, cachedInput: 0.025, output: 2.00 },
281
+ 'gpt-5-nano': { input: 0.05, cachedInput: 0.005, output: 0.40 },
282
+ 'gpt-4.1': { input: 2.00, cachedInput: 0.50, output: 8.00 },
283
+ 'gpt-4.1-mini': { input: 0.40, cachedInput: 0.10, output: 1.60 },
284
+ 'gpt-4.1-nano': { input: 0.10, cachedInput: 0.025, output: 0.40 },
65
285
  // gptOss (Together/Groq/Cerebras/OpenRouter)
66
- 'openai/gpt-oss-120b': [0.15, 0.60],
67
- 'gpt-oss-120b': [0.15, 0.60],
68
- 'openai/gpt-oss-120b:free': [0, 0],
286
+ 'openai/gpt-oss-120b': { input: 0.15, output: 0.60 },
287
+ 'gpt-oss-120b': { input: 0.15, output: 0.60 },
288
+ 'openai/gpt-oss-120b:free': { input: 0, output: 0 },
69
289
  // Anthropic
70
- 'claude-fable-5': [10.00, 50.00],
71
- 'claude-opus-5': [5.00, 25.00],
72
- 'claude-sonnet-5': [3.00, 15.00],
73
- 'claude-opus-4-8': [5.00, 25.00],
74
- 'claude-opus-4-7': [5.00, 25.00],
75
- 'claude-opus-4-6': [5.00, 25.00],
76
- 'claude-sonnet-4-6': [3.00, 15.00],
77
- 'claude-sonnet-4-5-20250929': [3.00, 15.00],
78
- 'claude-haiku-4-5-20251001': [1.00, 5.00],
290
+ 'claude-fable-5': { input: 10.00, cachedInput: 1.00, cacheWrite: 12.50, cacheWrite1h: 20.00, output: 50.00 },
291
+ 'claude-opus-5': { input: 5.00, cachedInput: 0.50, cacheWrite: 6.25, cacheWrite1h: 10.00, output: 25.00 },
292
+ 'claude-sonnet-5': { input: 3.00, cachedInput: 0.30, cacheWrite: 3.75, cacheWrite1h: 6.00, output: 15.00 },
293
+ 'claude-opus-4-8': { input: 5.00, cachedInput: 0.50, cacheWrite: 6.25, cacheWrite1h: 10.00, output: 25.00 },
294
+ 'claude-opus-4-7': { input: 5.00, cachedInput: 0.50, cacheWrite: 6.25, cacheWrite1h: 10.00, output: 25.00 },
295
+ 'claude-opus-4-6': { input: 5.00, cachedInput: 0.50, cacheWrite: 6.25, cacheWrite1h: 10.00, output: 25.00 },
296
+ 'claude-sonnet-4-6': { input: 3.00, cachedInput: 0.30, cacheWrite: 3.75, cacheWrite1h: 6.00, output: 15.00 },
297
+ 'claude-sonnet-4-5-20250929': { input: 3.00, cachedInput: 0.30, cacheWrite: 3.75, cacheWrite1h: 6.00, output: 15.00 },
298
+ 'claude-haiku-4-5-20251001': { input: 1.00, cachedInput: 0.10, cacheWrite: 1.25, cacheWrite1h: 2.00, output: 5.00 },
79
299
  // Google
80
- 'gemini-3.1-pro-preview':[2.00, 12.00],
81
- 'gemini-3-pro-preview': [2.00, 12.00],
82
- 'gemini-3-flash-preview': [0.50, 3.00],
83
- 'gemini-3.6-flash': [1.50, 7.50],
84
- 'gemini-3.5-flash': [0.75, 4.50],
85
- 'gemini-3.5-flash-lite': [0.30, 2.50],
86
- 'gemini-2.5-pro': [1.25, 10.00],
87
- 'gemini-2.5-flash': [0.30, 2.50],
88
- 'gemini-3.1-flash-lite-preview': [0.25, 1.50],
300
+ 'gemini-3.1-pro-preview': { input: 2.00, output: 12.00 },
301
+ 'gemini-3-pro-preview': { input: 2.00, output: 12.00 },
302
+ 'gemini-3-flash-preview': { input: 0.50, output: 3.00 },
303
+ 'gemini-3.6-flash': { input: 1.50, output: 7.50 },
304
+ 'gemini-3.5-flash': { input: 0.75, output: 4.50 },
305
+ 'gemini-3.5-flash-lite': { input: 0.30, output: 2.50 },
306
+ 'gemini-2.5-pro': { input: 1.25, output: 10.00 },
307
+ 'gemini-2.5-flash': { input: 0.30, output: 2.50 },
308
+ 'gemini-3.1-flash-lite-preview': { input: 0.25, output: 1.50 },
89
309
  // Grok
90
- 'grok-4.5': [2.00, 6.00],
91
- 'grok-4.3': [1.25, 2.50],
92
- 'grok-4.20-multi-agent-0309': [1.25, 2.50],
93
- 'grok-4.20-0309': [1.25, 2.50],
94
- 'grok-4.20-0309-reasoning': [1.25, 2.50],
95
- 'grok-4.20-0309-non-reasoning': [1.25, 2.50],
310
+ 'grok-4.5': { input: 2.00, output: 6.00 },
311
+ 'grok-4.3': { input: 1.25, output: 2.50 },
312
+ 'grok-4.20-multi-agent-0309': { input: 1.25, output: 2.50 },
313
+ 'grok-4.20-0309': { input: 1.25, output: 2.50 },
314
+ 'grok-4.20-0309-reasoning': { input: 1.25, output: 2.50 },
315
+ 'grok-4.20-0309-non-reasoning': { input: 1.25, output: 2.50 },
96
316
  // Fireworks
97
- 'accounts/fireworks/models/deepseek-v4-flash': [0.14, 0.28],
98
- 'accounts/fireworks/models/deepseek-v4-pro': [1.74, 3.48],
99
- 'deepseek-ai/DeepSeek-V4-Flash': [0.14, 0.28],
100
- 'deepseek-ai/DeepSeek-V4-Pro': [2.10, 4.40],
101
- 'deepseek/deepseek-v4-flash': [0.09, 0.18],
102
- 'accounts/fireworks/models/glm-4p7': [0.55, 2.19],
103
- 'accounts/fireworks/models/glm-5p1': [1.05, 3.50],
104
- 'zai-org/GLM-5.2': [1.40, 4.40],
105
- 'accounts/fireworks/models/kimi-k2p5': [0.50, 2.80],
106
- 'accounts/fireworks/models/qwen3p6-plus': [0.50, 3.00],
107
- 'Qwen/Qwen3.6-Plus': [0.50, 3.00],
108
- 'accounts/fireworks/models/qwen3p7-plus': [0.40, 1.60],
109
- 'qwen/qwen3.7-plus': [0.32, 1.28],
110
- 'qwen/qwen3.8-max': [2.00, 6.00],
317
+ 'accounts/fireworks/models/deepseek-v4-flash': { input: 0.14, output: 0.28 },
318
+ 'accounts/fireworks/models/deepseek-v4-pro': { input: 1.74, output: 3.48 },
319
+ 'deepseek-ai/DeepSeek-V4-Flash': { input: 0.14, output: 0.28 },
320
+ 'deepseek-ai/DeepSeek-V4-Pro': { input: 2.10, output: 4.40 },
321
+ 'deepseek/deepseek-v4-flash': { input: 0.09, output: 0.18 },
322
+ 'accounts/fireworks/models/glm-4p7': { input: 0.55, output: 2.19 },
323
+ 'accounts/fireworks/models/glm-5p1': { input: 1.05, output: 3.50 },
324
+ 'zai-org/GLM-5.2': { input: 1.40, output: 4.40 },
325
+ 'accounts/fireworks/models/kimi-k2p5': { input: 0.50, output: 2.80 },
326
+ 'accounts/fireworks/models/qwen3p6-plus': { input: 0.50, output: 3.00 },
327
+ 'Qwen/Qwen3.6-Plus': { input: 0.50, output: 3.00 },
328
+ 'accounts/fireworks/models/qwen3p7-plus': { input: 0.40, output: 1.60 },
329
+ 'qwen/qwen3.7-plus': { input: 0.32, output: 1.28 },
330
+ 'qwen/qwen3.8-max': { input: 2.00, output: 6.00 },
111
331
  // MiniMax
112
- 'MiniMax-M2.5': [0.30, 1.20],
113
- 'MiniMax-M2.7': [0.30, 1.20],
114
- 'MiniMax-M3': [0.30, 1.20],
115
- 'minimax/minimax-m2.7': [0.30, 1.20],
116
- 'minimax/minimax-m3': [0.30, 1.20],
117
- 'MiniMaxAI/MiniMax-M3': [0.30, 1.20],
332
+ 'MiniMax-M2.5': { input: 0.30, output: 1.20 },
333
+ 'MiniMax-M2.7': { input: 0.30, output: 1.20 },
334
+ 'MiniMax-M3': { input: 0.30, output: 1.20 },
335
+ 'minimax/minimax-m2.7': { input: 0.30, output: 1.20 },
336
+ 'minimax/minimax-m3': { input: 0.30, output: 1.20 },
337
+ 'MiniMaxAI/MiniMax-M3': { input: 0.30, output: 1.20 },
118
338
  // Perplexity
119
- 'sonar': [1.00, 1.00],
120
- 'sonar-pro': [3.00, 15.00],
339
+ 'sonar': { input: 1.00, output: 1.00 },
340
+ 'sonar-pro': { input: 3.00, output: 15.00 },
121
341
  // Hermes3 (Lambda/OpenRouter)
122
- 'Hermes-3-Llama-3.1-405B-FP8': [0.80, 0.80],
123
- 'nousresearch/hermes-3-llama-3.1-405b:free': [0, 0],
342
+ 'Hermes-3-Llama-3.1-405B-FP8': { input: 0.80, output: 0.80 },
343
+ 'nousresearch/hermes-3-llama-3.1-405b:free': { input: 0, output: 0 },
124
344
  // Qwen3 (Together/Cerebras)
125
- 'Qwen/Qwen3-235B-A22B-fp8-tput': [0.20, 0.60],
126
- 'qwen-3-32b': [0.20, 0.60],
345
+ 'Qwen/Qwen3-235B-A22B-fp8-tput': { input: 0.20, output: 0.60 },
346
+ 'qwen-3-32b': { input: 0.20, output: 0.60 },
127
347
  // Kimi K2.5 (Together/Fireworks/OpenRouter)
128
- 'moonshotai/Kimi-K2.5': [0.50, 2.80],
129
- 'moonshotai/kimi-k2.5': [0.50, 2.80],
348
+ 'moonshotai/Kimi-K2.5': { input: 0.50, output: 2.80 },
349
+ 'moonshotai/kimi-k2.5': { input: 0.50, output: 2.80 },
130
350
  // Kimi K3
131
- 'kimi-k3': [3.00, 15.00],
132
- 'moonshotai/kimi-k3': [3.00, 15.00],
351
+ 'kimi-k3': { input: 3.00, output: 15.00 },
352
+ 'moonshotai/kimi-k3': { input: 3.00, output: 15.00 },
133
353
  // GLM 4.7 (OpenRouter/Cerebras)
134
- 'z-ai/glm-4.7': [0.55, 2.19],
135
- 'zai-glm-4.7': [0.55, 2.19],
354
+ 'z-ai/glm-4.7': { input: 0.55, output: 2.19 },
355
+ 'zai-glm-4.7': { input: 0.55, output: 2.19 },
136
356
  };
137
357
 
138
358
  class ModelMix {
@@ -144,6 +364,8 @@ class ModelMix {
144
364
  this.toolClient = {};
145
365
  this.mcp = {};
146
366
  this.mcpToolsManager = new MCPToolsManager();
367
+ this.templateFileAssignments = new Map();
368
+ this.messageTemplates = new WeakMap();
147
369
  this.lastRaw = null;
148
370
  this.options = {
149
371
  max_tokens: 8192,
@@ -172,6 +394,13 @@ class ModelMix {
172
394
  roundRobin: false, // false=fallback mode, true=round robin rotation
173
395
  ...config
174
396
  };
397
+ this.systemTemplate = {
398
+ source: this.config.system,
399
+ filename: null
400
+ };
401
+ if (this.config.templateData !== undefined) {
402
+ validateTemplateData(this.config.templateData);
403
+ }
175
404
  // Unified effort is ModelMix policy (config.effort / .effort()), not a native option.
176
405
  if (this.config.effort !== undefined && this.config.effort !== null) {
177
406
  this.config.effort = normalizeEffort(this.config.effort);
@@ -183,11 +412,20 @@ class ModelMix {
183
412
 
184
413
  }
185
414
 
186
- replace(keyValues) {
187
- this.config.replace = { ...this.config.replace, ...keyValues };
415
+ assign(keyValues) {
416
+ validateTemplateData(keyValues);
417
+ for (const key of Object.keys(keyValues)) {
418
+ this.templateFileAssignments.delete(key);
419
+ }
420
+ this.config.templateData = { ...this.config.templateData, ...keyValues };
188
421
  return this;
189
422
  }
190
423
 
424
+ assignKey(key, value) {
425
+ validateTemplateDataKey(key);
426
+ return this.assign({ [key]: value });
427
+ }
428
+
191
429
  /**
192
430
  * Set unified reasoning effort: -1 (adaptive) or 0..100.
193
431
  * Stored in config.effort; mapped to provider-native fields at request time
@@ -203,11 +441,19 @@ class ModelMix {
203
441
  }
204
442
 
205
443
  new({ options = {}, config = {}, mix = {} } = {}) {
444
+ const hasSystemOverride = Object.prototype.hasOwnProperty.call(config, 'system');
206
445
  const instance = new ModelMix({
207
446
  options: { ...this.options, ...options },
208
447
  config: { ...this.config, ...config },
209
448
  mix: { ...this.mix, ...mix }
210
449
  });
450
+ if (!hasSystemOverride) {
451
+ instance.systemTemplate = { ...this.systemTemplate };
452
+ }
453
+ instance.templateFileAssignments = new Map(this.templateFileAssignments);
454
+ for (const key of Object.keys(config.templateData || {})) {
455
+ instance.templateFileAssignments.delete(key);
456
+ }
211
457
  instance.models = this.models; // Share models array for round-robin rotation
212
458
  return instance;
213
459
  }
@@ -238,20 +484,163 @@ class ModelMix {
238
484
  return str.length > maxLen ? str.substring(0, maxLen) + '...' : str;
239
485
  }
240
486
 
241
- static calculateCost(modelKey, tokens) {
487
+ static normalizeTokenUsage({ input = 0, output = 0, total, cached = 0, cacheWrite = 0, cacheWrite5m = 0, cacheWrite1h = 0 } = {}) {
488
+ const tokenCount = value => Number.isFinite(value) ? Math.max(0, value) : 0;
489
+ const normalizedInput = tokenCount(input);
490
+ const normalizedOutput = tokenCount(output);
491
+ const normalizedCached = tokenCount(cached);
492
+ const normalizedCacheWrite5m = tokenCount(cacheWrite5m);
493
+ const normalizedCacheWrite1h = tokenCount(cacheWrite1h);
494
+ const normalizedCacheWrite = Math.max(
495
+ tokenCount(cacheWrite),
496
+ normalizedCacheWrite5m + normalizedCacheWrite1h
497
+ );
498
+ const normalizedTotal = Number.isFinite(total)
499
+ ? Math.max(0, total)
500
+ : normalizedInput + normalizedOutput;
501
+ const uncachedInput = Math.max(0, normalizedInput - normalizedCached - normalizedCacheWrite);
502
+ const cacheHitRate = normalizedInput > 0
503
+ ? Number((normalizedCached / normalizedInput).toFixed(4))
504
+ : 0;
505
+
506
+ return {
507
+ input: normalizedInput,
508
+ output: normalizedOutput,
509
+ total: normalizedTotal,
510
+ cached: normalizedCached,
511
+ cacheWrite: normalizedCacheWrite,
512
+ cacheWrite5m: normalizedCacheWrite5m,
513
+ cacheWrite1h: normalizedCacheWrite1h,
514
+ uncachedInput,
515
+ cacheHitRate,
516
+ cacheSavings: 0,
517
+ cacheWritePremium: 0,
518
+ breakEvenHits: 0,
519
+ cost: 0,
520
+ costBreakdown: {
521
+ uncachedInput: 0,
522
+ cachedInput: 0,
523
+ cacheWrite: 0,
524
+ cacheWrite5m: 0,
525
+ cacheWrite1h: 0,
526
+ output: 0,
527
+ total: 0
528
+ }
529
+ };
530
+ }
531
+
532
+ static calculateCostBreakdown(modelKey, tokens) {
533
+ const pricing = MODEL_PRICING[modelKey];
534
+ if (!pricing) return ModelMix.normalizeTokenUsage().costBreakdown;
535
+
536
+ const normalized = ModelMix.normalizeTokenUsage(tokens);
537
+ const longContext = pricing.longContext;
538
+ const useLongContextRates = longContext && normalized.input > longContext.inputThreshold;
539
+ const inputMultiplier = useLongContextRates ? longContext.inputMultiplier : 1;
540
+ const outputMultiplier = useLongContextRates ? longContext.outputMultiplier : 1;
541
+ const {
542
+ input: inputPerMillion,
543
+ cachedInput: cachedInputPerMillion = inputPerMillion,
544
+ cacheWrite: cacheWritePerMillion = inputPerMillion,
545
+ cacheWrite1h: cacheWrite1hPerMillion = cacheWritePerMillion,
546
+ output: outputPerMillion
547
+ } = pricing;
548
+ const roundCost = value => Number(value.toFixed(12));
549
+ const genericCacheWrite = Math.max(
550
+ 0,
551
+ normalized.cacheWrite - normalized.cacheWrite5m - normalized.cacheWrite1h
552
+ );
553
+ const cacheWrite5mCost = roundCost(
554
+ normalized.cacheWrite5m * cacheWritePerMillion * inputMultiplier / 1_000_000
555
+ );
556
+ const cacheWrite1hCost = roundCost(
557
+ normalized.cacheWrite1h * cacheWrite1hPerMillion * inputMultiplier / 1_000_000
558
+ );
559
+ const genericCacheWriteCost = roundCost(
560
+ genericCacheWrite * cacheWritePerMillion * inputMultiplier / 1_000_000
561
+ );
562
+ const breakdown = {
563
+ uncachedInput: roundCost(normalized.uncachedInput * inputPerMillion * inputMultiplier / 1_000_000),
564
+ cachedInput: roundCost(normalized.cached * cachedInputPerMillion * inputMultiplier / 1_000_000),
565
+ cacheWrite: roundCost(genericCacheWriteCost + cacheWrite5mCost + cacheWrite1hCost),
566
+ cacheWrite5m: cacheWrite5mCost,
567
+ cacheWrite1h: cacheWrite1hCost,
568
+ output: roundCost(normalized.output * outputPerMillion * outputMultiplier / 1_000_000)
569
+ };
570
+ breakdown.total = roundCost(
571
+ breakdown.uncachedInput
572
+ + breakdown.cachedInput
573
+ + breakdown.cacheWrite
574
+ + breakdown.output
575
+ );
576
+ return breakdown;
577
+ }
578
+
579
+ static calculateCacheMetrics(modelKey, tokens) {
242
580
  const pricing = MODEL_PRICING[modelKey];
243
- if (!pricing) return null;
244
- const [inputPerMillion, outputPerMillion] = pricing;
245
- return (tokens.input * inputPerMillion / 1_000_000) + (tokens.output * outputPerMillion / 1_000_000);
581
+ const emptyMetrics = {
582
+ cacheSavings: 0,
583
+ cacheWritePremium: 0,
584
+ breakEvenHits: 0
585
+ };
586
+ if (!pricing) return emptyMetrics;
587
+
588
+ const normalized = ModelMix.normalizeTokenUsage(tokens);
589
+ const longContext = pricing.longContext;
590
+ const inputMultiplier = longContext && normalized.input > longContext.inputThreshold
591
+ ? longContext.inputMultiplier
592
+ : 1;
593
+ const cachedInputPerMillion = pricing.cachedInput ?? pricing.input;
594
+ const cacheWritePerMillion = pricing.cacheWrite ?? pricing.input;
595
+ const cacheWrite1hPerMillion = pricing.cacheWrite1h ?? cacheWritePerMillion;
596
+ const readSavingsPerMillion = Math.max(0, pricing.input - cachedInputPerMillion) * inputMultiplier;
597
+ const writePremiumPerMillion = Math.max(0, cacheWritePerMillion - pricing.input) * inputMultiplier;
598
+ const write1hPremiumPerMillion = Math.max(0, cacheWrite1hPerMillion - pricing.input) * inputMultiplier;
599
+ const roundCost = value => Number(value.toFixed(12));
600
+ const cacheSavings = roundCost(normalized.cached * readSavingsPerMillion / 1_000_000);
601
+ const genericCacheWrite = Math.max(
602
+ 0,
603
+ normalized.cacheWrite - normalized.cacheWrite5m - normalized.cacheWrite1h
604
+ );
605
+ const cacheWritePremium = roundCost(
606
+ (
607
+ (genericCacheWrite + normalized.cacheWrite5m) * writePremiumPerMillion
608
+ + normalized.cacheWrite1h * write1hPremiumPerMillion
609
+ ) / 1_000_000
610
+ );
611
+ const fullHitSavings = normalized.cacheWrite * readSavingsPerMillion / 1_000_000;
612
+
613
+ return {
614
+ cacheSavings,
615
+ cacheWritePremium,
616
+ breakEvenHits: fullHitSavings > 0
617
+ ? Number((cacheWritePremium / fullHitSavings).toFixed(4))
618
+ : 0
619
+ };
620
+ }
621
+
622
+ static calculateCost(modelKey, tokens) {
623
+ if (!MODEL_PRICING[modelKey]) return null;
624
+ return ModelMix.calculateCostBreakdown(modelKey, tokens).total;
246
625
  }
247
626
 
248
627
  static extractCacheTokens(usage = {}) {
249
628
  return usage.input_tokens_details?.cached_tokens
250
- || usage.prompt_tokens_details?.cached_tokens
251
- || usage.cache_read_input_tokens
252
- || usage.cachedContentTokenCount
253
- || usage.cached_content_token_count
254
- || 0;
629
+ ?? usage.prompt_tokens_details?.cached_tokens
630
+ ?? usage.cache_read_input_tokens
631
+ ?? usage.cachedContentTokenCount
632
+ ?? usage.cached_content_token_count
633
+ ?? 0;
634
+ }
635
+
636
+ static extractCacheWriteTokens(usage = {}) {
637
+ return usage.input_tokens_details?.cache_write_tokens
638
+ ?? usage.prompt_tokens_details?.cache_write_tokens
639
+ ?? usage.cache_creation_input_tokens
640
+ ?? usage.cache_write_input_tokens
641
+ ?? usage.cacheWriteTokenCount
642
+ ?? usage.cache_write_token_count
643
+ ?? 0;
255
644
  }
256
645
 
257
646
  static formatInputSummary(messages, system, debug = 2) {
@@ -385,12 +774,18 @@ class ModelMix {
385
774
  if (mix.openrouter) this.attach('openai/gpt-oss-120b:free', new MixOpenRouter({ options, config }));
386
775
  return this;
387
776
  }
388
- fable5({ options = {}, config = {} } = {}) {
777
+ fable50({ options = {}, config = {} } = {}) {
389
778
  return this.attach('claude-fable-5', new MixAnthropic({ options, config }));
390
779
  }
391
- opus5({ options = {}, config = {} } = {}) {
780
+ fable5(args = {}) {
781
+ return this.fable50(args);
782
+ }
783
+ opus50({ options = {}, config = {} } = {}) {
392
784
  return this.attach('claude-opus-5', new MixAnthropic({ options, config }));
393
785
  }
786
+ opus5(args = {}) {
787
+ return this.opus50(args);
788
+ }
394
789
  opus48({ options = {}, config = {} } = {}) {
395
790
  return this.attach('claude-opus-4-8', new MixAnthropic({ options, config }));
396
791
  }
@@ -400,9 +795,12 @@ class ModelMix {
400
795
  opus46({ options = {}, config = {} } = {}) {
401
796
  return this.attach('claude-opus-4-6', new MixAnthropic({ options, config }));
402
797
  }
403
- sonnet5({ options = {}, config = {} } = {}) {
798
+ sonnet50({ options = {}, config = {} } = {}) {
404
799
  return this.attach('claude-sonnet-5', new MixAnthropic({ options, config }));
405
800
  }
801
+ sonnet5(args = {}) {
802
+ return this.sonnet50(args);
803
+ }
406
804
  sonnet46({ options = {}, config = {} } = {}) {
407
805
  return this.attach('claude-sonnet-4-6', new MixAnthropic({ options, config }));
408
806
  }
@@ -597,34 +995,54 @@ class ModelMix {
597
995
  return this;
598
996
  }
599
997
 
600
- addText(text, { role = "user" } = {}) {
998
+ addText(text, { role = "user", cache } = {}) {
999
+ return this._addText(text, {
1000
+ role,
1001
+ cache: normalizeContentCache(cache),
1002
+ template: { source: text, filename: null }
1003
+ });
1004
+ }
1005
+
1006
+ _addText(text, { role = "user", cache, template = null } = {}) {
601
1007
  const content = [{
602
1008
  type: "text",
603
- text
1009
+ text,
1010
+ ...(cache !== undefined && { cache })
604
1011
  }];
605
1012
 
1013
+ if (template) {
1014
+ this.messageTemplates.set(content[0], template);
1015
+ }
606
1016
  this.messages.push({ role, content });
607
1017
  return this;
608
1018
  }
609
1019
 
610
- addTextFromFile(filePath, { role = "user" } = {}) {
611
- const content = this.readFile(filePath);
612
- this.addText(content, { role });
613
- return this;
1020
+ addTextFromFile(filePath, { role = "user", cache } = {}) {
1021
+ const filename = path.resolve(filePath);
1022
+ const content = this.readFile(filename);
1023
+ return this._addText(content, {
1024
+ role,
1025
+ cache: normalizeContentCache(cache),
1026
+ template: { source: content, filename }
1027
+ });
614
1028
  }
615
1029
 
616
1030
  setSystem(text) {
617
1031
  this.config.system = text;
1032
+ this.systemTemplate = { source: text, filename: null };
618
1033
  return this;
619
1034
  }
620
1035
 
621
1036
  setSystemFromFile(filePath) {
622
- const content = this.readFile(filePath);
623
- this.setSystem(content);
1037
+ const filename = path.resolve(filePath);
1038
+ const content = this.readFile(filename);
1039
+ this.config.system = content;
1040
+ this.systemTemplate = { source: content, filename };
624
1041
  return this;
625
1042
  }
626
1043
 
627
- addImageFromBuffer(buffer, { role = "user" } = {}) {
1044
+ addImageFromBuffer(buffer, { role = "user", cache } = {}) {
1045
+ const contentCache = normalizeContentCache(cache);
628
1046
  this.messages.push({
629
1047
  role,
630
1048
  content: [{
@@ -632,19 +1050,21 @@ class ModelMix {
632
1050
  source: {
633
1051
  type: "buffer",
634
1052
  data: buffer
635
- }
1053
+ },
1054
+ ...(contentCache !== undefined && { cache: contentCache })
636
1055
  }]
637
1056
  });
638
1057
  return this;
639
1058
  }
640
1059
 
641
- addImage(filePath, { role = "user" } = {}) {
1060
+ addImage(filePath, { role = "user", cache } = {}) {
642
1061
  const absolutePath = path.resolve(filePath);
643
1062
 
644
1063
  if (!fs.existsSync(absolutePath)) {
645
1064
  throw new Error(`Image file not found: ${filePath}`);
646
1065
  }
647
1066
 
1067
+ const contentCache = normalizeContentCache(cache);
648
1068
  this.messages.push({
649
1069
  role,
650
1070
  content: [{
@@ -652,13 +1072,14 @@ class ModelMix {
652
1072
  source: {
653
1073
  type: "file",
654
1074
  data: filePath
655
- }
1075
+ },
1076
+ ...(contentCache !== undefined && { cache: contentCache })
656
1077
  }]
657
1078
  });
658
1079
  return this;
659
1080
  }
660
1081
 
661
- addImageFromUrl(url, { role = "user" } = {}) {
1082
+ addImageFromUrl(url, { role = "user", cache } = {}) {
662
1083
  let source;
663
1084
  if (url.startsWith('data:')) {
664
1085
  // Parse data URL: data:image/jpeg;base64,/9j/4AAQ...
@@ -679,11 +1100,13 @@ class ModelMix {
679
1100
  };
680
1101
  }
681
1102
 
1103
+ const contentCache = normalizeContentCache(cache);
682
1104
  this.messages.push({
683
1105
  role,
684
1106
  content: [{
685
1107
  type: "image",
686
- source
1108
+ source,
1109
+ ...(contentCache !== undefined && { cache: contentCache })
687
1110
  }]
688
1111
  });
689
1112
 
@@ -732,7 +1155,7 @@ class ModelMix {
732
1155
 
733
1156
  // Update the content with processed image
734
1157
  message.content[j] = {
735
- type: "image",
1158
+ ...content,
736
1159
  source: {
737
1160
  type: "base64",
738
1161
  media_type: mimeType,
@@ -771,27 +1194,23 @@ class ModelMix {
771
1194
  stream: false,
772
1195
  }
773
1196
 
774
- // Apply template replacements to system before adding extra instructions
775
- let systemWithReplacements = this._template(this.config.system, this.config.replace);
776
-
777
- let config = {
778
- system: systemWithReplacements,
779
- }
1197
+ let config = {};
1198
+ let systemSuffix = '';
780
1199
 
781
1200
  if (schemaExample) {
782
1201
  config.schema = generateJsonSchema(schemaExample, schemaDescription);
783
1202
 
784
1203
  if (addSchema) {
785
- config.system += "\n\nOutput JSON Schema: \n```\n" + JSON.stringify(config.schema) + "\n```";
1204
+ systemSuffix += "\n\nOutput JSON Schema: \n```\n" + JSON.stringify(config.schema) + "\n```";
786
1205
  }
787
1206
  if (addExample) {
788
- config.system += "\n\nOutput JSON Example: \n```\n" + JSON.stringify(schemaExample) + "\n```";
1207
+ systemSuffix += "\n\nOutput JSON Example: \n```\n" + JSON.stringify(schemaExample) + "\n```";
789
1208
  }
790
1209
  if (addNote) {
791
- config.system += "\n\nOutput JSON Escape: double quotes, backslashes, and control characters inside JSON strings.\nEnsure the output contains no comments.";
1210
+ systemSuffix += "\n\nOutput JSON Escape: double quotes, backslashes, and control characters inside JSON strings.\nEnsure the output contains no comments.";
792
1211
  }
793
1212
  }
794
- const { message } = await this.execute({ options, config });
1213
+ const { message } = await this.execute({ options, config, systemSuffix });
795
1214
  const parsed = JSON.parse(this._extractBlock(message));
796
1215
  return isArrayWrap ? parsed.out : parsed;
797
1216
  }
@@ -802,17 +1221,13 @@ class ModelMix {
802
1221
  }
803
1222
 
804
1223
  async block({ addSystemExtra = true } = {}) {
805
- // Apply template replacements to system before adding extra instructions
806
- let systemWithReplacements = this._template(this.config.system, this.config.replace);
807
-
808
- let config = {
809
- system: systemWithReplacements,
810
- }
811
-
812
- if (addSystemExtra) {
813
- config.system += "\nReturn the result of the task between triple backtick block code tags ```";
814
- }
815
- const { message } = await this.execute({ options: { stream: false }, config });
1224
+ const systemSuffix = addSystemExtra
1225
+ ? "\nReturn the result of the task between triple backtick block code tags ```"
1226
+ : '';
1227
+ const { message } = await this.execute({
1228
+ options: { stream: false },
1229
+ systemSuffix
1230
+ });
816
1231
  return this._extractBlock(message);
817
1232
  }
818
1233
 
@@ -825,23 +1240,97 @@ class ModelMix {
825
1240
  return this.execute({ options: { stream: true } });
826
1241
  }
827
1242
 
828
- replaceKeyFromFile(key, filePath) {
829
- try {
830
- const content = this.readFile(filePath);
831
- this.replace({ [key]: this._template(content, this.config.replace) });
832
- } catch (error) {
833
- // Gracefully handle file read errors without throwing
834
- log.warn(`replaceKeyFromFile: ${error.message}`);
835
- }
1243
+ assignKeyFromFile(key, filePath) {
1244
+ validateTemplateDataKey(key);
1245
+ this.readFile(filePath);
1246
+
1247
+ const templateData = { ...this.config.templateData };
1248
+ delete templateData[key];
1249
+ this.config.templateData = templateData;
1250
+ this.templateFileAssignments.set(key, Object.freeze({
1251
+ key,
1252
+ filename: path.resolve(filePath)
1253
+ }));
836
1254
  return this;
837
1255
  }
838
1256
 
839
- _template(input, replace) {
840
- if (!replace) return input;
841
- for (const k in replace) {
842
- input = input.split(/([¿?¡!,"';:\(\)\.\s])/).map(x => x === k ? replace[k] : x).join("");
1257
+ _choiceRandom() {
1258
+ return Math.random();
1259
+ }
1260
+
1261
+ _templateData(renderContext) {
1262
+ const assigned = { ...(this.config.templateData || {}), $mix: renderContext.helpers };
1263
+ const data = { ...assigned };
1264
+
1265
+ for (const [key, assignment] of this.templateFileAssignments) {
1266
+ data[key] = this._renderAssignedTemplate(assignment, assigned, renderContext);
1267
+ }
1268
+
1269
+ return data;
1270
+ }
1271
+
1272
+ _renderAssignedTemplate(assignment, data, renderContext) {
1273
+ if (renderContext.renderedTemplateData.has(assignment)) {
1274
+ return renderContext.renderedTemplateData.get(assignment);
1275
+ }
1276
+
1277
+ const rendered = this._renderTemplateWithData(
1278
+ `<%- include(${JSON.stringify(assignment.filename)}) %>`,
1279
+ {
1280
+ filename: assignment.filename,
1281
+ label: `template data "${assignment.key}"`
1282
+ },
1283
+ data
1284
+ );
1285
+ renderContext.renderedTemplateData.set(assignment, rendered);
1286
+ return rendered;
1287
+ }
1288
+
1289
+ _renderTemplate(
1290
+ source,
1291
+ { filename = null, label = 'template' } = {},
1292
+ renderContext = createTemplateRenderContext(() => this._choiceRandom())
1293
+ ) {
1294
+ return this._renderTemplateWithData(
1295
+ source,
1296
+ { filename, label },
1297
+ this._templateData(renderContext)
1298
+ );
1299
+ }
1300
+
1301
+ _renderTemplateWithData(source, { filename = null, label = 'template' }, data) {
1302
+ if (typeof source !== 'string') {
1303
+ throw new TypeError(`${label} source must be a string.`);
1304
+ }
1305
+
1306
+ try {
1307
+ const template = preprocessChoiceDirectives(source, { filename, label });
1308
+ return ejs.render(template, data, {
1309
+ ...(filename && { filename }),
1310
+ async: false,
1311
+ cache: false,
1312
+ compileDebug: true,
1313
+ unsafePrototypeLocals: false,
1314
+ includer: (originalPath, resolvedFilename) => {
1315
+ if (!resolvedFilename) {
1316
+ throw new Error(`Could not find the include file "${originalPath}"`);
1317
+ }
1318
+ const includedSource = fs.readFileSync(resolvedFilename, 'utf8').replace(/^\uFEFF/, '');
1319
+ return {
1320
+ filename: resolvedFilename,
1321
+ template: preprocessChoiceDirectives(includedSource, {
1322
+ filename: resolvedFilename,
1323
+ label: 'included template'
1324
+ })
1325
+ };
1326
+ }
1327
+ });
1328
+ } catch (error) {
1329
+ const location = filename ? ` ${filename}` : '';
1330
+ const renderError = new Error(`Failed to render ${label}${location}: ${error.message}`);
1331
+ renderError.cause = error;
1332
+ throw renderError;
843
1333
  }
844
- return input;
845
1334
  }
846
1335
 
847
1336
  static hasToolInteraction(message) {
@@ -873,37 +1362,56 @@ class ModelMix {
873
1362
  }, []);
874
1363
  }
875
1364
 
876
- applyTemplate() {
877
- if (!this.config.replace) return;
878
-
879
- this.config.system = this._template(this.config.system, this.config.replace);
880
-
881
- this.messages = this.messages.map(message => {
882
- if (message.content instanceof Array) {
883
- message.content = message.content.map(content => {
884
- if (content.type === 'text') {
885
- content.text = this._template(content.text, this.config.replace);
1365
+ _renderMessageSnapshot(messages, renderContext) {
1366
+ return messages.map(message => ({
1367
+ ...message,
1368
+ content: Array.isArray(message.content)
1369
+ ? message.content.map(content => {
1370
+ if (!content || typeof content !== 'object') return content;
1371
+
1372
+ const snapshotContent = { ...content };
1373
+ const template = content.type === 'text'
1374
+ ? this.messageTemplates.get(content)
1375
+ : null;
1376
+ if (!template) return snapshotContent;
1377
+
1378
+ let rendered = renderContext.renderedMessages.get(content)?.rendered;
1379
+ if (rendered === undefined) {
1380
+ rendered = this._renderTemplate(template.source, {
1381
+ filename: template.filename,
1382
+ label: 'message template'
1383
+ }, renderContext);
1384
+ renderContext.renderedMessages.set(content, { rendered, template });
886
1385
  }
887
- return content;
888
- });
889
- }
890
- return message;
891
- });
1386
+ snapshotContent.text = rendered;
1387
+ return snapshotContent;
1388
+ })
1389
+ : message.content
1390
+ }));
1391
+ }
1392
+
1393
+ _commitTemplateRenderContext(renderContext) {
1394
+ for (const [content, { rendered, template }] of renderContext.renderedMessages) {
1395
+ if (this.messageTemplates.get(content) !== template) continue;
1396
+ content.text = rendered;
1397
+ this.messageTemplates.delete(content);
1398
+ }
892
1399
  }
893
1400
 
894
- async prepareMessages() {
1401
+ async prepareMessages(renderContext = createTemplateRenderContext(() => this._choiceRandom())) {
895
1402
  await this.processImages();
896
- this.applyTemplate();
1403
+
1404
+ let messages = this.messages;
897
1405
 
898
1406
  // Smart message slicing based on max_history:
899
1407
  // 0 = no history (stateless), N = keep last N messages, -1 = unlimited
900
1408
  if (this.config.max_history > 0) {
901
- let sliceStart = Math.max(0, this.messages.length - this.config.max_history);
1409
+ let sliceStart = Math.max(0, messages.length - this.config.max_history);
902
1410
 
903
1411
  // If we're slicing into the middle of a tool interaction,
904
1412
  // backtrack to include the full sequence (user → assistant/tool_calls → tool results)
905
- while (sliceStart > 0 && sliceStart < this.messages.length) {
906
- const msg = this.messages[sliceStart];
1413
+ while (sliceStart > 0 && sliceStart < messages.length) {
1414
+ const msg = messages[sliceStart];
907
1415
  if (ModelMix.hasToolInteraction(msg)) {
908
1416
  sliceStart--;
909
1417
  } else {
@@ -911,13 +1419,13 @@ class ModelMix {
911
1419
  }
912
1420
  }
913
1421
 
914
- this.messages = this.messages.slice(sliceStart);
1422
+ this.messages = messages.slice(sliceStart);
1423
+ messages = this.messages;
915
1424
  }
916
1425
  // max_history = -1: unlimited, no slicing
917
1426
  // max_history = 0: no history, messages only contain what was added since last call
918
1427
 
919
- this.messages = this.groupByRoles(this.messages);
920
- this.options.messages = this.messages;
1428
+ return this.groupByRoles(this._renderMessageSnapshot(messages, renderContext));
921
1429
  }
922
1430
 
923
1431
  readFile(filePath, { encoding = 'utf8' } = {}) {
@@ -935,15 +1443,30 @@ class ModelMix {
935
1443
  }
936
1444
  }
937
1445
 
938
- async execute({ config = {}, options = {} } = {}) {
1446
+ _resolveSystemTemplate(config, providerConfig) {
1447
+ if (Object.prototype.hasOwnProperty.call(config, 'system')) {
1448
+ return { source: config.system, filename: null };
1449
+ }
1450
+ if (Object.prototype.hasOwnProperty.call(providerConfig, 'system')) {
1451
+ return { source: providerConfig.system, filename: null };
1452
+ }
1453
+ if (this.config.system !== this.systemTemplate.source) {
1454
+ return { source: this.config.system, filename: null };
1455
+ }
1456
+ return this.systemTemplate;
1457
+ }
1458
+
1459
+ async execute({ config = {}, options = {}, systemSuffix = '', _templateContext = null } = {}) {
939
1460
  if (!this.models || this.models.length === 0) {
940
1461
  throw new Error("No models specified. Use methods like .gpt5(), .sonnet46() first.");
941
1462
  }
942
1463
 
943
- return this.limiter.schedule(async () => {
944
- await this.prepareMessages();
1464
+ const isRootExecution = _templateContext === null;
1465
+ const templateContext = _templateContext || createTemplateRenderContext(() => this._choiceRandom());
1466
+ const execution = this.limiter.schedule(async () => {
1467
+ const preparedMessages = await this.prepareMessages(templateContext);
945
1468
 
946
- if (this.messages.length === 0) {
1469
+ if (preparedMessages.length === 0) {
947
1470
  throw new Error("No user messages have been added. Use addText(prompt), addTextFromFile(filePath), addImage(filePath), or addImageFromUrl(url) to add a prompt.");
948
1471
  }
949
1472
 
@@ -978,6 +1501,7 @@ class ModelMix {
978
1501
  // Create clean copies for each provider to avoid contamination
979
1502
  const currentOptions = {
980
1503
  ...this.options,
1504
+ messages: preparedMessages,
981
1505
  ...providerInstance.options,
982
1506
  ...optionsTools,
983
1507
  ...options,
@@ -994,6 +1518,18 @@ class ModelMix {
994
1518
  ...(config.retry || {})
995
1519
  }
996
1520
  };
1521
+ const systemTemplate = this._resolveSystemTemplate(config, providerInstance.config);
1522
+ const systemCacheKey = JSON.stringify([systemTemplate.filename, systemTemplate.source]);
1523
+ if (!templateContext.renderedSystems.has(systemCacheKey)) {
1524
+ templateContext.renderedSystems.set(
1525
+ systemCacheKey,
1526
+ this._renderTemplate(systemTemplate.source, {
1527
+ filename: systemTemplate.filename,
1528
+ label: 'system template'
1529
+ }, templateContext)
1530
+ );
1531
+ }
1532
+ currentConfig.system = templateContext.renderedSystems.get(systemCacheKey) + systemSuffix;
997
1533
 
998
1534
  // Grok 4.20 alias → reasoning / non-reasoning from unified effort
999
1535
  const resolvedModelKey = resolveGrok420ModelKey(
@@ -1018,7 +1554,7 @@ class ModelMix {
1018
1554
  const header = `\n${prefix} [${providerName}:${resolvedModelKey}] #${originalIndex + 1}${suffix}`;
1019
1555
 
1020
1556
  if (currentConfig.debug >= 2) {
1021
- console.log(`${header}\n${ModelMix.formatInputSummary(this.messages, currentConfig.system, currentConfig.debug)}`);
1557
+ console.log(`${header}\n${ModelMix.formatInputSummary(preparedMessages, currentConfig.system, currentConfig.debug)}`);
1022
1558
  } else {
1023
1559
  console.log(header);
1024
1560
  }
@@ -1072,7 +1608,16 @@ class ModelMix {
1072
1608
  const elapsedMs = Date.now() - startTime;
1073
1609
 
1074
1610
  if (result.tokens) {
1075
- result.tokens.cost = ModelMix.calculateCost(resolvedModelKey, result.tokens);
1611
+ const normalizedTokens = ModelMix.normalizeTokenUsage(result.tokens);
1612
+ const costBreakdown = ModelMix.calculateCostBreakdown(resolvedModelKey, normalizedTokens);
1613
+ const cacheMetrics = ModelMix.calculateCacheMetrics(resolvedModelKey, normalizedTokens);
1614
+ result.tokens = {
1615
+ ...result.tokens,
1616
+ ...normalizedTokens,
1617
+ ...cacheMetrics,
1618
+ cost: MODEL_PRICING[resolvedModelKey] ? costBreakdown.total : 0,
1619
+ costBreakdown
1620
+ };
1076
1621
  const elapsedSec = elapsedMs / 1000;
1077
1622
  result.tokens.speed = elapsedSec > 0 ? Math.round(result.tokens.output / elapsedSec) : 0;
1078
1623
  }
@@ -1091,7 +1636,7 @@ class ModelMix {
1091
1636
  }]
1092
1637
  });
1093
1638
  } else {
1094
- this.addText(result.message, { role: "assistant" });
1639
+ this._addText(result.message, { role: "assistant" });
1095
1640
  }
1096
1641
  }
1097
1642
 
@@ -1109,7 +1654,7 @@ class ModelMix {
1109
1654
  });
1110
1655
  }
1111
1656
 
1112
- return this.execute({ options, config });
1657
+ return this.execute({ options, config, systemSuffix, _templateContext: templateContext });
1113
1658
  }
1114
1659
 
1115
1660
  // debug level 1: Just success indicator
@@ -1171,7 +1716,7 @@ class ModelMix {
1171
1716
  }]
1172
1717
  });
1173
1718
  } else {
1174
- this.addText(result.message, { role: "assistant" });
1719
+ this._addText(result.message, { role: "assistant" });
1175
1720
  }
1176
1721
  }
1177
1722
 
@@ -1197,6 +1742,12 @@ class ModelMix {
1197
1742
  log.error("Fallback logic completed without success or throwing the final error.");
1198
1743
  throw lastError || new Error("Failed to get response from any model, and no specific error was caught.");
1199
1744
  });
1745
+
1746
+ if (!isRootExecution) return execution;
1747
+
1748
+ const result = await execution;
1749
+ this._commitTemplateRenderContext(templateContext);
1750
+ return result;
1200
1751
  }
1201
1752
 
1202
1753
  async processToolCalls(toolCalls) {
@@ -1397,6 +1948,13 @@ class MixCustom {
1397
1948
  return MixOpenAI.convertMessages(messages, config);
1398
1949
  }
1399
1950
 
1951
+ sanitizeCacheOptions(options) {
1952
+ delete options.cache_control;
1953
+ delete options.prompt_cache_key;
1954
+ delete options.prompt_cache_options;
1955
+ delete options.prompt_cache_retention;
1956
+ }
1957
+
1400
1958
  static stripContentTypeHeader(headers = {}) {
1401
1959
  return stripContentTypeHeader(headers);
1402
1960
  }
@@ -1411,6 +1969,7 @@ class MixCustom {
1411
1969
 
1412
1970
  async create({ config = {}, options = {} } = {}) {
1413
1971
  try {
1972
+ this.sanitizeCacheOptions(options);
1414
1973
  if (Array.isArray(options.messages)) {
1415
1974
  options.messages = this.convertMessages(options.messages, config);
1416
1975
  }
@@ -1422,9 +1981,7 @@ class MixCustom {
1422
1981
  console.log('\n[REQUEST DETAILS]');
1423
1982
 
1424
1983
  console.log('\n[CONFIG]');
1425
- const configToLog = { ...config };
1426
- delete configToLog.debug;
1427
- console.log(ModelMix.formatJSON(configToLog));
1984
+ console.log(ModelMix.formatJSON(configForDebug(config)));
1428
1985
 
1429
1986
  console.log('\n[OPTIONS]');
1430
1987
  console.log(ModelMix.formatJSON(request.options));
@@ -1444,11 +2001,11 @@ class MixCustom {
1444
2001
  }));
1445
2002
  }
1446
2003
  } catch (error) {
1447
- throw this.handleError(error, { config, options });
2004
+ throw this.handleError(error);
1448
2005
  }
1449
2006
  }
1450
2007
 
1451
- handleError(error, { config, options }) {
2008
+ handleError(error) {
1452
2009
  let errorMessage = 'An error occurred in MixCustom';
1453
2010
  let statusCode = null;
1454
2011
  let errorDetails = null;
@@ -1462,12 +2019,10 @@ class MixCustom {
1462
2019
  }
1463
2020
 
1464
2021
  const formattedError = {
1465
- message: errorMessage,
2022
+ message: redactSecret(errorMessage, this.config.apiKey),
1466
2023
  statusCode,
1467
- details: errorDetails,
1468
- stack: error.stack,
1469
- config: config,
1470
- options: options
2024
+ details: redactSecret(errorDetails, this.config.apiKey),
2025
+ stack: redactSecret(error.stack, this.config.apiKey)
1471
2026
  };
1472
2027
 
1473
2028
  return formattedError;
@@ -1586,19 +2141,15 @@ class MixCustom {
1586
2141
  static extractTokens(data) {
1587
2142
  // OpenAI/Groq/Together/Lambda/Cerebras/Fireworks format
1588
2143
  if (data.usage) {
1589
- return {
2144
+ return ModelMix.normalizeTokenUsage({
1590
2145
  input: data.usage.prompt_tokens || 0,
1591
2146
  output: data.usage.completion_tokens || 0,
1592
- total: data.usage.total_tokens || 0,
1593
- cached: ModelMix.extractCacheTokens(data.usage)
1594
- };
2147
+ total: data.usage.total_tokens,
2148
+ cached: ModelMix.extractCacheTokens(data.usage),
2149
+ cacheWrite: ModelMix.extractCacheWriteTokens(data.usage)
2150
+ });
1595
2151
  }
1596
- return {
1597
- input: 0,
1598
- output: 0,
1599
- total: 0,
1600
- cached: 0
1601
- };
2152
+ return ModelMix.normalizeTokenUsage();
1602
2153
  }
1603
2154
 
1604
2155
  processResponse(response) {
@@ -1617,6 +2168,11 @@ class MixCustom {
1617
2168
  }
1618
2169
 
1619
2170
  class MixOpenAI extends MixCustom {
2171
+ sanitizeCacheOptions(options) {
2172
+ delete options.cache_control;
2173
+ delete options.prompt_cache_options;
2174
+ }
2175
+
1620
2176
  getDefaultConfig(customConfig) {
1621
2177
 
1622
2178
  if (!process.env.OPENAI_API_KEY) {
@@ -1690,22 +2246,26 @@ class MixOpenAI extends MixCustom {
1690
2246
  continue;
1691
2247
  }
1692
2248
 
2249
+ let convertedMessage = { ...message };
1693
2250
  if (Array.isArray(message.content)) {
1694
- message.content = message.content.filter(content => content !== null && content !== undefined).map(content => {
1695
- if (content && content.type === 'image') {
1696
- const { media_type, data } = content.source;
1697
- return {
1698
- type: 'image_url',
1699
- image_url: {
1700
- url: `data:${media_type};base64,${data}`
1701
- }
1702
- };
1703
- }
1704
- return content;
1705
- });
2251
+ convertedMessage = {
2252
+ ...message,
2253
+ content: message.content.filter(content => content !== null && content !== undefined).map(content => {
2254
+ if (content && content.type === 'image') {
2255
+ const { media_type, data } = content.source;
2256
+ return {
2257
+ type: 'image_url',
2258
+ image_url: {
2259
+ url: `data:${media_type};base64,${data}`
2260
+ }
2261
+ };
2262
+ }
2263
+ return stripContentCacheMetadata(content);
2264
+ })
2265
+ };
1706
2266
  }
1707
2267
 
1708
- results.push(message);
2268
+ results.push(convertedMessage);
1709
2269
  }
1710
2270
 
1711
2271
  return results;
@@ -1765,10 +2325,14 @@ class MixOpenAIResponses extends MixOpenAI {
1765
2325
  }
1766
2326
 
1767
2327
  static buildResponsesRequest(options = {}, config = {}) {
1768
- const input = MixOpenAIResponses.messagesToResponsesInput(options.messages);
2328
+ const isGPT56 = typeof options.model === 'string' && options.model.startsWith('gpt-5.6');
2329
+ const input = MixOpenAIResponses.messagesToResponsesInput(options.messages, {
2330
+ translateNeutralCache: isGPT56
2331
+ });
1769
2332
  if (config.system) {
1770
2333
  input.unshift({ role: 'developer', content: [{ type: 'input_text', text: config.system }] });
1771
2334
  }
2335
+ MixOpenAIResponses.validatePromptCaching(options, input);
1772
2336
  const request = {
1773
2337
  model: options.model,
1774
2338
  input,
@@ -1812,10 +2376,52 @@ class MixOpenAIResponses extends MixOpenAI {
1812
2376
  if (options.user !== undefined) request.user = options.user;
1813
2377
  if (options.prompt_cache_key !== undefined) request.prompt_cache_key = options.prompt_cache_key;
1814
2378
  if (options.prompt_cache_retention !== undefined) request.prompt_cache_retention = options.prompt_cache_retention;
2379
+ if (options.prompt_cache_options !== undefined) request.prompt_cache_options = options.prompt_cache_options;
1815
2380
 
1816
2381
  return request;
1817
2382
  }
1818
2383
 
2384
+ static validatePromptCaching(options, input) {
2385
+ const isGPT56 = typeof options.model === 'string' && options.model.startsWith('gpt-5.6');
2386
+ const cacheOptions = options.prompt_cache_options;
2387
+ const breakpoints = input.flatMap(message => Array.isArray(message.content)
2388
+ ? message.content
2389
+ .filter(block => block?.prompt_cache_breakpoint !== undefined)
2390
+ .map(block => block.prompt_cache_breakpoint)
2391
+ : []);
2392
+
2393
+ if (isGPT56 && options.prompt_cache_retention !== undefined) {
2394
+ throw new Error('GPT-5.6 does not support prompt_cache_retention; use prompt_cache_options.ttl instead.');
2395
+ }
2396
+ if (!isGPT56 && cacheOptions !== undefined) {
2397
+ throw new Error('prompt_cache_options is only supported by GPT-5.6 models.');
2398
+ }
2399
+ if (!isGPT56 && breakpoints.length > 0) {
2400
+ throw new Error('prompt_cache_breakpoint is only supported by GPT-5.6 models.');
2401
+ }
2402
+ if (cacheOptions !== undefined) {
2403
+ if (!isPlainObject(cacheOptions)) {
2404
+ throw new TypeError('prompt_cache_options must be a plain non-null object.');
2405
+ }
2406
+ if (cacheOptions.mode !== undefined
2407
+ && cacheOptions.mode !== 'implicit'
2408
+ && cacheOptions.mode !== 'explicit') {
2409
+ throw new TypeError('prompt_cache_options.mode must be "implicit" or "explicit".');
2410
+ }
2411
+ if (cacheOptions.ttl !== undefined && cacheOptions.ttl !== '30m') {
2412
+ throw new TypeError('prompt_cache_options.ttl must be "30m".');
2413
+ }
2414
+ }
2415
+ for (const breakpoint of breakpoints) {
2416
+ if (!isPlainObject(breakpoint)) {
2417
+ throw new TypeError('prompt_cache_breakpoint must be a plain non-null object.');
2418
+ }
2419
+ if (breakpoint.mode !== 'explicit') {
2420
+ throw new TypeError('prompt_cache_breakpoint mode must be "explicit".');
2421
+ }
2422
+ }
2423
+ }
2424
+
1819
2425
  static processResponsesResponse(response) {
1820
2426
  const message = MixOpenAIResponses.extractResponsesMessage(response.data);
1821
2427
  return {
@@ -1829,19 +2435,15 @@ class MixOpenAIResponses extends MixOpenAI {
1829
2435
 
1830
2436
  static extractResponsesTokens(data) {
1831
2437
  if (data.usage) {
1832
- return {
2438
+ return ModelMix.normalizeTokenUsage({
1833
2439
  input: data.usage.input_tokens || 0,
1834
2440
  output: data.usage.output_tokens || 0,
1835
- total: data.usage.total_tokens || ((data.usage.input_tokens || 0) + (data.usage.output_tokens || 0)),
1836
- cached: ModelMix.extractCacheTokens(data.usage)
1837
- };
2441
+ total: data.usage.total_tokens,
2442
+ cached: ModelMix.extractCacheTokens(data.usage),
2443
+ cacheWrite: ModelMix.extractCacheWriteTokens(data.usage)
2444
+ });
1838
2445
  }
1839
- return {
1840
- input: 0,
1841
- output: 0,
1842
- total: 0,
1843
- cached: 0
1844
- };
2446
+ return ModelMix.normalizeTokenUsage();
1845
2447
  }
1846
2448
 
1847
2449
  static extractResponsesMessage(data) {
@@ -1855,27 +2457,70 @@ class MixOpenAIResponses extends MixOpenAI {
1855
2457
  .trim();
1856
2458
  }
1857
2459
 
1858
- static messagesToResponsesInput(messages = []) {
2460
+ static messagesToResponsesInput(messages = [], { translateNeutralCache = false } = {}) {
1859
2461
  const mapped = [];
1860
2462
 
1861
2463
  for (const message of messages) {
1862
2464
  if (!message || !message.role) continue;
1863
2465
  if (message.tool_calls || message.role === 'tool') continue;
1864
2466
 
1865
- let text = '';
2467
+ const content = [];
2468
+ const isAssistant = message.role === 'assistant';
2469
+ const textType = isAssistant ? 'output_text' : 'input_text';
1866
2470
  if (typeof message.content === 'string') {
1867
- text = message.content;
2471
+ if (message.content) content.push({ type: textType, text: message.content });
1868
2472
  } else if (Array.isArray(message.content)) {
1869
- text = message.content
1870
- .filter(item => item && item.type === 'text' && typeof item.text === 'string')
1871
- .map(item => item.text)
1872
- .join('\n');
2473
+ for (const item of message.content) {
2474
+ if (!item || typeof item !== 'object') continue;
2475
+ const neutralCache = item.cache !== undefined
2476
+ ? normalizeContentCache(item.cache)
2477
+ : undefined;
2478
+ const promptCacheBreakpoint = item.prompt_cache_breakpoint !== undefined
2479
+ ? item.prompt_cache_breakpoint
2480
+ : (translateNeutralCache && neutralCache?.breakpoint
2481
+ ? { mode: 'explicit' }
2482
+ : undefined);
2483
+ const breakpoint = !isAssistant && promptCacheBreakpoint !== undefined
2484
+ ? { prompt_cache_breakpoint: promptCacheBreakpoint }
2485
+ : {};
2486
+
2487
+ if ((item.type === 'text' || item.type === 'input_text' || item.type === 'output_text')
2488
+ && typeof item.text === 'string') {
2489
+ content.push({ type: textType, text: item.text, ...breakpoint });
2490
+ continue;
2491
+ }
2492
+ if (item.type === 'image' && item.source) {
2493
+ let imageUrl;
2494
+ if (item.source.type === 'base64') {
2495
+ if (!item.source.media_type || typeof item.source.data !== 'string') {
2496
+ throw new TypeError('Responses base64 images require source.media_type and string source.data.');
2497
+ }
2498
+ imageUrl = `data:${item.source.media_type};base64,${item.source.data}`;
2499
+ } else if (item.source.type === 'url' && typeof item.source.data === 'string') {
2500
+ imageUrl = item.source.data;
2501
+ } else {
2502
+ throw new TypeError('Responses images must be processed to base64 or use a URL source.');
2503
+ }
2504
+ content.push({ type: 'input_image', image_url: imageUrl, ...breakpoint });
2505
+ continue;
2506
+ }
2507
+ if (item.type === 'image_url' && typeof item.image_url?.url === 'string') {
2508
+ content.push({ type: 'input_image', image_url: item.image_url.url, ...breakpoint });
2509
+ continue;
2510
+ }
2511
+ if (item.type === 'input_image' || item.type === 'input_file') {
2512
+ content.push({
2513
+ ...stripContentCacheMetadata(item),
2514
+ ...breakpoint
2515
+ });
2516
+ }
2517
+ }
1873
2518
  }
1874
2519
 
1875
- if (!text) continue;
2520
+ if (content.length === 0) continue;
1876
2521
  mapped.push({
1877
2522
  role: message.role,
1878
- content: [{ type: 'input_text', text }]
2523
+ content
1879
2524
  });
1880
2525
  }
1881
2526
 
@@ -1928,9 +2573,7 @@ class MixOpenAIWebSocket extends MixOpenAIResponses {
1928
2573
  reject({
1929
2574
  message: `Realtime WebSocket timed out after ${timeoutMs}ms`,
1930
2575
  statusCode: null,
1931
- details: null,
1932
- config: mergedConfig,
1933
- options
2576
+ details: null
1934
2577
  });
1935
2578
  }, timeoutMs);
1936
2579
 
@@ -2024,9 +2667,7 @@ class MixOpenAIWebSocket extends MixOpenAIResponses {
2024
2667
  reject({
2025
2668
  message: event.error?.message || 'Realtime WebSocket error',
2026
2669
  statusCode: null,
2027
- details: event.error || event,
2028
- config: mergedConfig,
2029
- options
2670
+ details: event.error || event
2030
2671
  });
2031
2672
  }
2032
2673
  });
@@ -2039,9 +2680,7 @@ class MixOpenAIWebSocket extends MixOpenAIResponses {
2039
2680
  message: error.message || 'Realtime WebSocket connection error',
2040
2681
  statusCode: null,
2041
2682
  details: null,
2042
- stack: error.stack,
2043
- config: mergedConfig,
2044
- options
2683
+ stack: error.stack
2045
2684
  });
2046
2685
  });
2047
2686
 
@@ -2052,9 +2691,7 @@ class MixOpenAIWebSocket extends MixOpenAIResponses {
2052
2691
  reject({
2053
2692
  message: 'Realtime WebSocket closed before response.done',
2054
2693
  statusCode: null,
2055
- details: null,
2056
- config: mergedConfig,
2057
- options
2694
+ details: null
2058
2695
  });
2059
2696
  });
2060
2697
  });
@@ -2155,6 +2792,23 @@ class MixKimi extends MixOpenAI {
2155
2792
 
2156
2793
  class MixAnthropic extends MixCustom {
2157
2794
 
2795
+ sanitizeCacheOptions(options) {
2796
+ delete options.prompt_cache_key;
2797
+ delete options.prompt_cache_options;
2798
+ delete options.prompt_cache_retention;
2799
+ }
2800
+
2801
+ static validateCacheControl(cacheControl) {
2802
+ if (!isPlainObject(cacheControl) || cacheControl.type !== 'ephemeral') {
2803
+ throw new TypeError('Anthropic cache_control must have type "ephemeral".');
2804
+ }
2805
+ if (cacheControl.ttl !== undefined
2806
+ && cacheControl.ttl !== '5m'
2807
+ && cacheControl.ttl !== '1h') {
2808
+ throw new TypeError('Anthropic cache_control.ttl must be "5m" or "1h".');
2809
+ }
2810
+ }
2811
+
2158
2812
  /**
2159
2813
  * Opus 4.7+ and Claude 5 family reject sampling params (temperature/top_p/top_k).
2160
2814
  * See: https://platform.claude.com/docs/en/about-claude/models/migration-guide
@@ -2200,10 +2854,20 @@ class MixAnthropic extends MixCustom {
2200
2854
  delete options.top_k;
2201
2855
  }
2202
2856
 
2857
+ const requestConfig = { ...config };
2858
+ if (hasNeutralCacheBreakpoint(options.messages)) {
2859
+ const contentCacheControl = options.cache_control ?? { type: 'ephemeral' };
2860
+ MixAnthropic.validateCacheControl(contentCacheControl);
2861
+ requestConfig._contentCacheControl = { ...contentCacheControl };
2862
+ delete options.cache_control;
2863
+ } else if (options.cache_control !== undefined) {
2864
+ MixAnthropic.validateCacheControl(options.cache_control);
2865
+ }
2866
+
2203
2867
  options.system = config.system;
2204
2868
 
2205
2869
  try {
2206
- return await super.create({ config, options });
2870
+ return await super.create({ config: requestConfig, options });
2207
2871
  } catch (error) {
2208
2872
  // Log the error details for debugging
2209
2873
  if (error.response && error.response.data) {
@@ -2275,20 +2939,37 @@ class MixAnthropic extends MixCustom {
2275
2939
 
2276
2940
  // Handle content conversion for other messages
2277
2941
  if (message.content && Array.isArray(message.content)) {
2278
- message.content = message.content.filter(content => content !== null && content !== undefined).map(content => {
2942
+ const content = message.content.filter(content => content !== null && content !== undefined).map(content => {
2943
+ const neutralCache = content?.cache !== undefined
2944
+ ? normalizeContentCache(content.cache)
2945
+ : undefined;
2946
+ if (neutralCache && content.cache_control !== undefined) {
2947
+ throw new TypeError('Use either cache or cache_control on an Anthropic content block, not both.');
2948
+ }
2949
+ let converted = content;
2279
2950
  if (content && content.type === 'function') {
2280
- return {
2951
+ converted = {
2281
2952
  type: 'tool_use',
2282
2953
  id: content.id,
2283
2954
  name: content.function.name,
2284
2955
  input: JSON.parse(content.function.arguments)
2285
- }
2956
+ };
2286
2957
  }
2287
- return content;
2958
+ const sanitized = stripContentCacheMetadata(converted);
2959
+ if (content.cache_control !== undefined) {
2960
+ MixAnthropic.validateCacheControl(content.cache_control);
2961
+ sanitized.cache_control = { ...content.cache_control };
2962
+ } else if (neutralCache?.breakpoint) {
2963
+ sanitized.cache_control = {
2964
+ ...(config?._contentCacheControl || { type: 'ephemeral' })
2965
+ };
2966
+ }
2967
+ return sanitized;
2288
2968
  });
2969
+ return { ...message, content };
2289
2970
  }
2290
2971
 
2291
- return message;
2972
+ return { ...message };
2292
2973
  });
2293
2974
  }
2294
2975
 
@@ -2370,19 +3051,26 @@ class MixAnthropic extends MixCustom {
2370
3051
  static extractTokens(data) {
2371
3052
  // Anthropic format
2372
3053
  if (data.usage) {
2373
- return {
2374
- input: data.usage.input_tokens || 0,
2375
- output: data.usage.output_tokens || 0,
2376
- total: (data.usage.input_tokens || 0) + (data.usage.output_tokens || 0),
2377
- cached: ModelMix.extractCacheTokens(data.usage)
2378
- };
3054
+ const cached = ModelMix.extractCacheTokens(data.usage);
3055
+ const cacheWrite5m = data.usage.cache_creation?.ephemeral_5m_input_tokens ?? 0;
3056
+ const cacheWrite1h = data.usage.cache_creation?.ephemeral_1h_input_tokens ?? 0;
3057
+ const cacheWrite = Math.max(
3058
+ ModelMix.extractCacheWriteTokens(data.usage),
3059
+ cacheWrite5m + cacheWrite1h
3060
+ );
3061
+ const input = (data.usage.input_tokens || 0) + cached + cacheWrite;
3062
+ const output = data.usage.output_tokens || 0;
3063
+ return ModelMix.normalizeTokenUsage({
3064
+ input,
3065
+ output,
3066
+ total: input + output,
3067
+ cached,
3068
+ cacheWrite,
3069
+ cacheWrite5m,
3070
+ cacheWrite1h
3071
+ });
2379
3072
  }
2380
- return {
2381
- input: 0,
2382
- output: 0,
2383
- total: 0,
2384
- cached: 0
2385
- };
3073
+ return ModelMix.normalizeTokenUsage();
2386
3074
  }
2387
3075
 
2388
3076
  processResponse(response) {
@@ -2566,6 +3254,13 @@ class MixGrok extends MixOpenAI {
2566
3254
  ...customConfig
2567
3255
  });
2568
3256
  }
3257
+
3258
+ async create({ config = {}, options = {} } = {}) {
3259
+ if (options.model === GROK420_REASONING || options.model === GROK420_NON_REASONING) {
3260
+ delete options.reasoning_effort;
3261
+ }
3262
+ return super.create({ config, options });
3263
+ }
2569
3264
  }
2570
3265
 
2571
3266
  class MixLambda extends MixCustom {
@@ -2740,6 +3435,7 @@ class MixGoogle extends MixCustom {
2740
3435
  return super.getDefaultConfig({
2741
3436
  url: 'https://generativelanguage.googleapis.com/v1beta/models',
2742
3437
  apiKey: process.env.GEMINI_API_KEY,
3438
+ ...customConfig
2743
3439
  });
2744
3440
  }
2745
3441
 
@@ -2909,9 +3605,7 @@ class MixGoogle extends MixCustom {
2909
3605
  console.log('\n[REQUEST DETAILS - GOOGLE]');
2910
3606
 
2911
3607
  console.log('\n[CONFIG]');
2912
- const configToLog = { ...config };
2913
- delete configToLog.debug;
2914
- console.log(ModelMix.formatJSON(configToLog));
3608
+ console.log(ModelMix.formatJSON(configForDebug(config)));
2915
3609
 
2916
3610
  console.log('\n[PAYLOAD]');
2917
3611
  console.log(ModelMix.formatJSON(payload));
@@ -2927,7 +3621,7 @@ class MixGoogle extends MixCustom {
2927
3621
  }));
2928
3622
  }
2929
3623
  } catch (error) {
2930
- throw this.handleError(error, { config, options });
3624
+ throw this.handleError(error);
2931
3625
  }
2932
3626
  }
2933
3627
 
@@ -2965,19 +3659,15 @@ class MixGoogle extends MixCustom {
2965
3659
  static extractTokens(data) {
2966
3660
  // Google Gemini format
2967
3661
  if (data.usageMetadata) {
2968
- return {
3662
+ return ModelMix.normalizeTokenUsage({
2969
3663
  input: data.usageMetadata.promptTokenCount || 0,
2970
3664
  output: data.usageMetadata.candidatesTokenCount || 0,
2971
- total: data.usageMetadata.totalTokenCount || 0,
2972
- cached: ModelMix.extractCacheTokens(data.usageMetadata)
2973
- };
3665
+ total: data.usageMetadata.totalTokenCount,
3666
+ cached: ModelMix.extractCacheTokens(data.usageMetadata),
3667
+ cacheWrite: ModelMix.extractCacheWriteTokens(data.usageMetadata)
3668
+ });
2974
3669
  }
2975
- return {
2976
- input: 0,
2977
- output: 0,
2978
- total: 0,
2979
- cached: 0
2980
- };
3670
+ return ModelMix.normalizeTokenUsage();
2981
3671
  }
2982
3672
 
2983
3673
  static stripUnsupportedSchemaProps(schema) {
@@ -3024,4 +3714,4 @@ class MixGoogle extends MixCustom {
3024
3714
  }
3025
3715
  }
3026
3716
 
3027
- module.exports = { MixCustom, ModelMix, MixAnthropic, MixKimi, MixMiniMax, MixMiMo, MixOpenAI, MixOpenAIResponses, MixOpenAIWebSocket, MixOpenRouter, MixPerplexity, MixOllama, MixLMStudio, MixGroq, MixTogether, MixGrok, MixCerebras, MixGoogle, MixFireworks, MixNVIDIA, normalizeEffort, applyUnifiedEffort, resolveProviderFamily };
3717
+ module.exports = { MixCustom, ModelMix, MixAnthropic, MixKimi, MixMiniMax, MixMiMo, MixOpenAI, MixOpenAIResponses, MixOpenAIWebSocket, MixOpenRouter, MixPerplexity, MixOllama, MixLMStudio, MixGroq, MixTogether, MixGrok, MixCerebras, MixGoogle, MixFireworks, MixNVIDIA, normalizeEffort, applyUnifiedEffort, resolveProviderFamily };