modelmix 4.7.4 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,309 @@ 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 templateLocation({ filename, label }, lineNumber) {
83
+ return `${filename || label} at line ${lineNumber}`;
84
+ }
85
+
86
+ function preprocessChoiceDirectives(source, { filename = null, label = 'template' } = {}) {
87
+ const parts = source.split(/(\r\n|\n|\r)/);
88
+ const blocks = [];
89
+
90
+ for (let index = 0; index < parts.length; index += 2) {
91
+ const line = parts[index];
92
+ const trimmed = line.trim();
93
+ const lineNumber = (index / 2) + 1;
94
+ const location = templateLocation({ filename, label }, lineNumber);
95
+ const newline = parts[index + 1] || '';
96
+
97
+ if (/^<%\s*choice\s*%>$/.test(trimmed)) {
98
+ const parent = blocks[blocks.length - 1];
99
+ if (parent && parent.optionCount === 0) {
100
+ throw new Error(`A nested choice must be inside an option (${location}).`);
101
+ }
102
+ blocks.push({ lineNumber, optionCount: 0, weighted: null });
103
+ parts[index] = '<% $mix.choice(option => { -%>';
104
+ continue;
105
+ }
106
+
107
+ const optionMatch = trimmed.match(/^<%\s*option(?:\s+(.+?))?\s*%>$/);
108
+ if (optionMatch) {
109
+ const block = blocks[blocks.length - 1];
110
+ if (!block) {
111
+ throw new Error(`Option directive must be inside a choice (${location}).`);
112
+ }
113
+
114
+ const weightText = optionMatch[1];
115
+ const weighted = weightText !== undefined;
116
+ if (block.weighted !== null && block.weighted !== weighted) {
117
+ throw new Error(`Choice options must either all have weights or all omit them (${location}).`);
118
+ }
119
+
120
+ let argument = '';
121
+ if (weighted) {
122
+ const weight = Number(weightText);
123
+ if (!Number.isFinite(weight) || weight <= 0) {
124
+ throw new Error(`Choice weight must be a positive finite number (${location}).`);
125
+ }
126
+ argument = `${weight}, `;
127
+ }
128
+
129
+ block.weighted = weighted;
130
+ parts[index] = `<% ${block.optionCount > 0 ? '}); ' : ''}option(${argument}() => { -%>`;
131
+ block.optionCount += 1;
132
+ continue;
133
+ }
134
+
135
+ if (/^<%\s*\/choice\s*%>$/.test(trimmed)) {
136
+ const block = blocks.pop();
137
+ if (!block) {
138
+ throw new Error(`Closing choice directive has no matching opening directive (${location}).`);
139
+ }
140
+ if (block.optionCount === 0) {
141
+ throw new Error(`Choice must contain at least one option (${location}).`);
142
+ }
143
+ parts[index] = '<% }); }); -%>';
144
+ continue;
145
+ }
146
+
147
+ if (/^<%\s*(?:choice|option|\/choice)(?:\s|%>)/.test(trimmed)) {
148
+ throw new Error(`Invalid choice directive (${location}).`);
149
+ }
150
+
151
+ const block = blocks[blocks.length - 1];
152
+ if (block && block.optionCount === 0) {
153
+ if (trimmed) {
154
+ throw new Error(`Choice content must be inside an option (${location}).`);
155
+ }
156
+ parts[index] = '<%# -%>';
157
+ }
158
+
159
+ if (newline) parts[index + 1] = newline;
160
+ }
161
+
162
+ if (blocks.length > 0) {
163
+ const block = blocks[blocks.length - 1];
164
+ throw new Error(`Unclosed choice directive (${templateLocation({ filename, label }, block.lineNumber)}).`);
165
+ }
166
+
167
+ return parts.join('');
168
+ }
169
+
170
+ function createTemplateRenderContext(random = Math.random) {
171
+ const choice = defineOptions => {
172
+ if (typeof defineOptions !== 'function') {
173
+ throw new TypeError('$mix.choice expects an option definition callback.');
174
+ }
175
+
176
+ const options = [];
177
+ let weighted = null;
178
+ const option = (weightOrRender, renderOption) => {
179
+ const hasWeight = renderOption !== undefined;
180
+ const weight = hasWeight ? weightOrRender : 1;
181
+ const render = hasWeight ? renderOption : weightOrRender;
182
+
183
+ if (weighted !== null && weighted !== hasWeight) {
184
+ throw new TypeError('$mix.choice options cannot mix weighted and unweighted forms.');
185
+ }
186
+ if (!Number.isFinite(weight) || weight <= 0) {
187
+ throw new TypeError('$mix.choice weights must be positive finite numbers.');
188
+ }
189
+ if (typeof render !== 'function') {
190
+ throw new TypeError('$mix.choice options require a render callback.');
191
+ }
192
+
193
+ weighted = hasWeight;
194
+ options.push({ weight, render });
195
+ };
196
+
197
+ defineOptions(option);
198
+ if (options.length === 0) {
199
+ throw new Error('$mix.choice requires at least one option.');
200
+ }
201
+
202
+ const totalWeight = options.reduce((sum, current) => sum + current.weight, 0);
203
+ if (!Number.isFinite(totalWeight)) {
204
+ throw new TypeError('$mix.choice total weight must be finite.');
205
+ }
206
+
207
+ let target = random() * totalWeight;
208
+ for (const current of options) {
209
+ target -= current.weight;
210
+ if (target < 0) return current.render();
211
+ }
212
+ return options[options.length - 1].render();
213
+ };
214
+
215
+ return {
216
+ helpers: Object.freeze({ choice }),
217
+ renderedMessages: new Map(),
218
+ renderedSystems: new Map()
219
+ };
220
+ }
221
+
222
+ function configForDebug(config) {
223
+ const safeConfig = { ...config };
224
+ delete safeConfig.apiKey;
225
+ delete safeConfig.debug;
226
+ return safeConfig;
227
+ }
228
+
229
+ function redactSecret(value, secret, seen = new WeakSet()) {
230
+ if (!secret) return value;
231
+ if (typeof value === 'string') return value.split(secret).join('[REDACTED]');
232
+ if (!value || typeof value !== 'object') return value;
233
+ if (seen.has(value)) return '[Circular]';
234
+
235
+ seen.add(value);
236
+ if (Array.isArray(value)) {
237
+ return value.map(item => redactSecret(item, secret, seen));
238
+ }
239
+ return Object.fromEntries(
240
+ Object.entries(value).map(([key, item]) => [key, redactSecret(item, secret, seen)])
241
+ );
242
+ }
243
+
244
+ // Pricing per 1M tokens in USD
41
245
  // Based on provider pricing pages linked in README
246
+ const GPT56_LONG_CONTEXT_PRICING = Object.freeze({
247
+ inputThreshold: 272_000,
248
+ inputMultiplier: 2,
249
+ outputMultiplier: 1.5
250
+ });
251
+
42
252
  const MODEL_PRICING = {
43
253
  // 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],
254
+ 'gpt-realtime-mini': { input: 0.60, cachedInput: 0.06, output: 2.40 },
255
+ 'gpt-realtime': { input: 4.00, cachedInput: 0.40, output: 16.00 },
256
+ 'gpt-5.6-sol': { input: 5.00, cachedInput: 0.50, cacheWrite: 6.25, output: 30.00, longContext: GPT56_LONG_CONTEXT_PRICING },
257
+ 'gpt-5.6-terra': { input: 2.00, cachedInput: 0.20, cacheWrite: 2.50, output: 12.00, longContext: GPT56_LONG_CONTEXT_PRICING },
258
+ 'gpt-5.6-luna': { input: 0.20, cachedInput: 0.02, cacheWrite: 0.25, output: 1.20, longContext: GPT56_LONG_CONTEXT_PRICING },
259
+ 'gpt-5.5-pro': { input: 30.00, output: 180.00 },
260
+ 'gpt-5.5': { input: 5.00, cachedInput: 0.50, output: 30.00 },
261
+ 'gpt-5.4': { input: 2.50, cachedInput: 0.25, output: 15.00 },
262
+ 'gpt-5.4-pro': { input: 30.00, output: 180.00 },
263
+ 'gpt-5.4-mini': { input: 0.75, cachedInput: 0.075, output: 4.50 },
264
+ 'gpt-5.4-nano': { input: 0.20, cachedInput: 0.02, output: 1.25 },
265
+ 'gpt-5.3-codex': { input: 1.75, cachedInput: 0.175, output: 14.00 },
266
+ 'gpt-5.2': { input: 1.75, cachedInput: 0.175, output: 14.00 },
267
+ 'gpt-5.2-chat-latest': { input: 1.75, cachedInput: 0.175, output: 14.00 },
268
+ 'gpt-5.1': { input: 1.25, cachedInput: 0.125, output: 10.00 },
269
+ 'gpt-5': { input: 1.25, cachedInput: 0.125, output: 10.00 },
270
+ 'gpt-5-mini': { input: 0.25, cachedInput: 0.025, output: 2.00 },
271
+ 'gpt-5-nano': { input: 0.05, cachedInput: 0.005, output: 0.40 },
272
+ 'gpt-4.1': { input: 2.00, cachedInput: 0.50, output: 8.00 },
273
+ 'gpt-4.1-mini': { input: 0.40, cachedInput: 0.10, output: 1.60 },
274
+ 'gpt-4.1-nano': { input: 0.10, cachedInput: 0.025, output: 0.40 },
65
275
  // 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],
276
+ 'openai/gpt-oss-120b': { input: 0.15, output: 0.60 },
277
+ 'gpt-oss-120b': { input: 0.15, output: 0.60 },
278
+ 'openai/gpt-oss-120b:free': { input: 0, output: 0 },
69
279
  // 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],
280
+ 'claude-fable-5': { input: 10.00, cachedInput: 1.00, cacheWrite: 12.50, cacheWrite1h: 20.00, output: 50.00 },
281
+ 'claude-opus-5': { input: 5.00, cachedInput: 0.50, cacheWrite: 6.25, cacheWrite1h: 10.00, output: 25.00 },
282
+ 'claude-sonnet-5': { input: 3.00, cachedInput: 0.30, cacheWrite: 3.75, cacheWrite1h: 6.00, output: 15.00 },
283
+ 'claude-opus-4-8': { input: 5.00, cachedInput: 0.50, cacheWrite: 6.25, cacheWrite1h: 10.00, output: 25.00 },
284
+ 'claude-opus-4-7': { input: 5.00, cachedInput: 0.50, cacheWrite: 6.25, cacheWrite1h: 10.00, output: 25.00 },
285
+ 'claude-opus-4-6': { input: 5.00, cachedInput: 0.50, cacheWrite: 6.25, cacheWrite1h: 10.00, output: 25.00 },
286
+ 'claude-sonnet-4-6': { input: 3.00, cachedInput: 0.30, cacheWrite: 3.75, cacheWrite1h: 6.00, output: 15.00 },
287
+ 'claude-sonnet-4-5-20250929': { input: 3.00, cachedInput: 0.30, cacheWrite: 3.75, cacheWrite1h: 6.00, output: 15.00 },
288
+ 'claude-haiku-4-5-20251001': { input: 1.00, cachedInput: 0.10, cacheWrite: 1.25, cacheWrite1h: 2.00, output: 5.00 },
79
289
  // 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],
290
+ 'gemini-3.1-pro-preview': { input: 2.00, output: 12.00 },
291
+ 'gemini-3-pro-preview': { input: 2.00, output: 12.00 },
292
+ 'gemini-3-flash-preview': { input: 0.50, output: 3.00 },
293
+ 'gemini-3.6-flash': { input: 1.50, output: 7.50 },
294
+ 'gemini-3.5-flash': { input: 0.75, output: 4.50 },
295
+ 'gemini-3.5-flash-lite': { input: 0.30, output: 2.50 },
296
+ 'gemini-2.5-pro': { input: 1.25, output: 10.00 },
297
+ 'gemini-2.5-flash': { input: 0.30, output: 2.50 },
298
+ 'gemini-3.1-flash-lite-preview': { input: 0.25, output: 1.50 },
89
299
  // 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],
300
+ 'grok-4.5': { input: 2.00, output: 6.00 },
301
+ 'grok-4.3': { input: 1.25, output: 2.50 },
302
+ 'grok-4.20-multi-agent-0309': { input: 1.25, output: 2.50 },
303
+ 'grok-4.20-0309': { input: 1.25, output: 2.50 },
304
+ 'grok-4.20-0309-reasoning': { input: 1.25, output: 2.50 },
305
+ 'grok-4.20-0309-non-reasoning': { input: 1.25, output: 2.50 },
96
306
  // 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],
307
+ 'accounts/fireworks/models/deepseek-v4-flash': { input: 0.14, output: 0.28 },
308
+ 'accounts/fireworks/models/deepseek-v4-pro': { input: 1.74, output: 3.48 },
309
+ 'deepseek-ai/DeepSeek-V4-Flash': { input: 0.14, output: 0.28 },
310
+ 'deepseek-ai/DeepSeek-V4-Pro': { input: 2.10, output: 4.40 },
311
+ 'deepseek/deepseek-v4-flash': { input: 0.09, output: 0.18 },
312
+ 'accounts/fireworks/models/glm-4p7': { input: 0.55, output: 2.19 },
313
+ 'accounts/fireworks/models/glm-5p1': { input: 1.05, output: 3.50 },
314
+ 'zai-org/GLM-5.2': { input: 1.40, output: 4.40 },
315
+ 'accounts/fireworks/models/kimi-k2p5': { input: 0.50, output: 2.80 },
316
+ 'accounts/fireworks/models/qwen3p6-plus': { input: 0.50, output: 3.00 },
317
+ 'Qwen/Qwen3.6-Plus': { input: 0.50, output: 3.00 },
318
+ 'accounts/fireworks/models/qwen3p7-plus': { input: 0.40, output: 1.60 },
319
+ 'qwen/qwen3.7-plus': { input: 0.32, output: 1.28 },
320
+ 'qwen/qwen3.8-max': { input: 2.00, output: 6.00 },
111
321
  // 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],
322
+ 'MiniMax-M2.5': { input: 0.30, output: 1.20 },
323
+ 'MiniMax-M2.7': { input: 0.30, output: 1.20 },
324
+ 'MiniMax-M3': { input: 0.30, output: 1.20 },
325
+ 'minimax/minimax-m2.7': { input: 0.30, output: 1.20 },
326
+ 'minimax/minimax-m3': { input: 0.30, output: 1.20 },
327
+ 'MiniMaxAI/MiniMax-M3': { input: 0.30, output: 1.20 },
118
328
  // Perplexity
119
- 'sonar': [1.00, 1.00],
120
- 'sonar-pro': [3.00, 15.00],
329
+ 'sonar': { input: 1.00, output: 1.00 },
330
+ 'sonar-pro': { input: 3.00, output: 15.00 },
121
331
  // 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],
332
+ 'Hermes-3-Llama-3.1-405B-FP8': { input: 0.80, output: 0.80 },
333
+ 'nousresearch/hermes-3-llama-3.1-405b:free': { input: 0, output: 0 },
124
334
  // Qwen3 (Together/Cerebras)
125
- 'Qwen/Qwen3-235B-A22B-fp8-tput': [0.20, 0.60],
126
- 'qwen-3-32b': [0.20, 0.60],
335
+ 'Qwen/Qwen3-235B-A22B-fp8-tput': { input: 0.20, output: 0.60 },
336
+ 'qwen-3-32b': { input: 0.20, output: 0.60 },
127
337
  // Kimi K2.5 (Together/Fireworks/OpenRouter)
128
- 'moonshotai/Kimi-K2.5': [0.50, 2.80],
129
- 'moonshotai/kimi-k2.5': [0.50, 2.80],
338
+ 'moonshotai/Kimi-K2.5': { input: 0.50, output: 2.80 },
339
+ 'moonshotai/kimi-k2.5': { input: 0.50, output: 2.80 },
130
340
  // Kimi K3
131
- 'kimi-k3': [3.00, 15.00],
132
- 'moonshotai/kimi-k3': [3.00, 15.00],
341
+ 'kimi-k3': { input: 3.00, output: 15.00 },
342
+ 'moonshotai/kimi-k3': { input: 3.00, output: 15.00 },
133
343
  // GLM 4.7 (OpenRouter/Cerebras)
134
- 'z-ai/glm-4.7': [0.55, 2.19],
135
- 'zai-glm-4.7': [0.55, 2.19],
344
+ 'z-ai/glm-4.7': { input: 0.55, output: 2.19 },
345
+ 'zai-glm-4.7': { input: 0.55, output: 2.19 },
136
346
  };
137
347
 
138
348
  class ModelMix {
@@ -144,6 +354,7 @@ class ModelMix {
144
354
  this.toolClient = {};
145
355
  this.mcp = {};
146
356
  this.mcpToolsManager = new MCPToolsManager();
357
+ this.messageTemplates = new WeakMap();
147
358
  this.lastRaw = null;
148
359
  this.options = {
149
360
  max_tokens: 8192,
@@ -172,6 +383,13 @@ class ModelMix {
172
383
  roundRobin: false, // false=fallback mode, true=round robin rotation
173
384
  ...config
174
385
  };
386
+ this.systemTemplate = {
387
+ source: this.config.system,
388
+ filename: null
389
+ };
390
+ if (this.config.replace !== undefined) {
391
+ validateTemplateData(this.config.replace);
392
+ }
175
393
  // Unified effort is ModelMix policy (config.effort / .effort()), not a native option.
176
394
  if (this.config.effort !== undefined && this.config.effort !== null) {
177
395
  this.config.effort = normalizeEffort(this.config.effort);
@@ -184,6 +402,7 @@ class ModelMix {
184
402
  }
185
403
 
186
404
  replace(keyValues) {
405
+ validateTemplateData(keyValues);
187
406
  this.config.replace = { ...this.config.replace, ...keyValues };
188
407
  return this;
189
408
  }
@@ -203,11 +422,15 @@ class ModelMix {
203
422
  }
204
423
 
205
424
  new({ options = {}, config = {}, mix = {} } = {}) {
425
+ const hasSystemOverride = Object.prototype.hasOwnProperty.call(config, 'system');
206
426
  const instance = new ModelMix({
207
427
  options: { ...this.options, ...options },
208
428
  config: { ...this.config, ...config },
209
429
  mix: { ...this.mix, ...mix }
210
430
  });
431
+ if (!hasSystemOverride) {
432
+ instance.systemTemplate = { ...this.systemTemplate };
433
+ }
211
434
  instance.models = this.models; // Share models array for round-robin rotation
212
435
  return instance;
213
436
  }
@@ -238,20 +461,163 @@ class ModelMix {
238
461
  return str.length > maxLen ? str.substring(0, maxLen) + '...' : str;
239
462
  }
240
463
 
241
- static calculateCost(modelKey, tokens) {
464
+ static normalizeTokenUsage({ input = 0, output = 0, total, cached = 0, cacheWrite = 0, cacheWrite5m = 0, cacheWrite1h = 0 } = {}) {
465
+ const tokenCount = value => Number.isFinite(value) ? Math.max(0, value) : 0;
466
+ const normalizedInput = tokenCount(input);
467
+ const normalizedOutput = tokenCount(output);
468
+ const normalizedCached = tokenCount(cached);
469
+ const normalizedCacheWrite5m = tokenCount(cacheWrite5m);
470
+ const normalizedCacheWrite1h = tokenCount(cacheWrite1h);
471
+ const normalizedCacheWrite = Math.max(
472
+ tokenCount(cacheWrite),
473
+ normalizedCacheWrite5m + normalizedCacheWrite1h
474
+ );
475
+ const normalizedTotal = Number.isFinite(total)
476
+ ? Math.max(0, total)
477
+ : normalizedInput + normalizedOutput;
478
+ const uncachedInput = Math.max(0, normalizedInput - normalizedCached - normalizedCacheWrite);
479
+ const cacheHitRate = normalizedInput > 0
480
+ ? Number((normalizedCached / normalizedInput).toFixed(4))
481
+ : 0;
482
+
483
+ return {
484
+ input: normalizedInput,
485
+ output: normalizedOutput,
486
+ total: normalizedTotal,
487
+ cached: normalizedCached,
488
+ cacheWrite: normalizedCacheWrite,
489
+ cacheWrite5m: normalizedCacheWrite5m,
490
+ cacheWrite1h: normalizedCacheWrite1h,
491
+ uncachedInput,
492
+ cacheHitRate,
493
+ cacheSavings: 0,
494
+ cacheWritePremium: 0,
495
+ breakEvenHits: 0,
496
+ cost: 0,
497
+ costBreakdown: {
498
+ uncachedInput: 0,
499
+ cachedInput: 0,
500
+ cacheWrite: 0,
501
+ cacheWrite5m: 0,
502
+ cacheWrite1h: 0,
503
+ output: 0,
504
+ total: 0
505
+ }
506
+ };
507
+ }
508
+
509
+ static calculateCostBreakdown(modelKey, tokens) {
242
510
  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);
511
+ if (!pricing) return ModelMix.normalizeTokenUsage().costBreakdown;
512
+
513
+ const normalized = ModelMix.normalizeTokenUsage(tokens);
514
+ const longContext = pricing.longContext;
515
+ const useLongContextRates = longContext && normalized.input > longContext.inputThreshold;
516
+ const inputMultiplier = useLongContextRates ? longContext.inputMultiplier : 1;
517
+ const outputMultiplier = useLongContextRates ? longContext.outputMultiplier : 1;
518
+ const {
519
+ input: inputPerMillion,
520
+ cachedInput: cachedInputPerMillion = inputPerMillion,
521
+ cacheWrite: cacheWritePerMillion = inputPerMillion,
522
+ cacheWrite1h: cacheWrite1hPerMillion = cacheWritePerMillion,
523
+ output: outputPerMillion
524
+ } = pricing;
525
+ const roundCost = value => Number(value.toFixed(12));
526
+ const genericCacheWrite = Math.max(
527
+ 0,
528
+ normalized.cacheWrite - normalized.cacheWrite5m - normalized.cacheWrite1h
529
+ );
530
+ const cacheWrite5mCost = roundCost(
531
+ normalized.cacheWrite5m * cacheWritePerMillion * inputMultiplier / 1_000_000
532
+ );
533
+ const cacheWrite1hCost = roundCost(
534
+ normalized.cacheWrite1h * cacheWrite1hPerMillion * inputMultiplier / 1_000_000
535
+ );
536
+ const genericCacheWriteCost = roundCost(
537
+ genericCacheWrite * cacheWritePerMillion * inputMultiplier / 1_000_000
538
+ );
539
+ const breakdown = {
540
+ uncachedInput: roundCost(normalized.uncachedInput * inputPerMillion * inputMultiplier / 1_000_000),
541
+ cachedInput: roundCost(normalized.cached * cachedInputPerMillion * inputMultiplier / 1_000_000),
542
+ cacheWrite: roundCost(genericCacheWriteCost + cacheWrite5mCost + cacheWrite1hCost),
543
+ cacheWrite5m: cacheWrite5mCost,
544
+ cacheWrite1h: cacheWrite1hCost,
545
+ output: roundCost(normalized.output * outputPerMillion * outputMultiplier / 1_000_000)
546
+ };
547
+ breakdown.total = roundCost(
548
+ breakdown.uncachedInput
549
+ + breakdown.cachedInput
550
+ + breakdown.cacheWrite
551
+ + breakdown.output
552
+ );
553
+ return breakdown;
554
+ }
555
+
556
+ static calculateCacheMetrics(modelKey, tokens) {
557
+ const pricing = MODEL_PRICING[modelKey];
558
+ const emptyMetrics = {
559
+ cacheSavings: 0,
560
+ cacheWritePremium: 0,
561
+ breakEvenHits: 0
562
+ };
563
+ if (!pricing) return emptyMetrics;
564
+
565
+ const normalized = ModelMix.normalizeTokenUsage(tokens);
566
+ const longContext = pricing.longContext;
567
+ const inputMultiplier = longContext && normalized.input > longContext.inputThreshold
568
+ ? longContext.inputMultiplier
569
+ : 1;
570
+ const cachedInputPerMillion = pricing.cachedInput ?? pricing.input;
571
+ const cacheWritePerMillion = pricing.cacheWrite ?? pricing.input;
572
+ const cacheWrite1hPerMillion = pricing.cacheWrite1h ?? cacheWritePerMillion;
573
+ const readSavingsPerMillion = Math.max(0, pricing.input - cachedInputPerMillion) * inputMultiplier;
574
+ const writePremiumPerMillion = Math.max(0, cacheWritePerMillion - pricing.input) * inputMultiplier;
575
+ const write1hPremiumPerMillion = Math.max(0, cacheWrite1hPerMillion - pricing.input) * inputMultiplier;
576
+ const roundCost = value => Number(value.toFixed(12));
577
+ const cacheSavings = roundCost(normalized.cached * readSavingsPerMillion / 1_000_000);
578
+ const genericCacheWrite = Math.max(
579
+ 0,
580
+ normalized.cacheWrite - normalized.cacheWrite5m - normalized.cacheWrite1h
581
+ );
582
+ const cacheWritePremium = roundCost(
583
+ (
584
+ (genericCacheWrite + normalized.cacheWrite5m) * writePremiumPerMillion
585
+ + normalized.cacheWrite1h * write1hPremiumPerMillion
586
+ ) / 1_000_000
587
+ );
588
+ const fullHitSavings = normalized.cacheWrite * readSavingsPerMillion / 1_000_000;
589
+
590
+ return {
591
+ cacheSavings,
592
+ cacheWritePremium,
593
+ breakEvenHits: fullHitSavings > 0
594
+ ? Number((cacheWritePremium / fullHitSavings).toFixed(4))
595
+ : 0
596
+ };
597
+ }
598
+
599
+ static calculateCost(modelKey, tokens) {
600
+ if (!MODEL_PRICING[modelKey]) return null;
601
+ return ModelMix.calculateCostBreakdown(modelKey, tokens).total;
246
602
  }
247
603
 
248
604
  static extractCacheTokens(usage = {}) {
249
605
  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;
606
+ ?? usage.prompt_tokens_details?.cached_tokens
607
+ ?? usage.cache_read_input_tokens
608
+ ?? usage.cachedContentTokenCount
609
+ ?? usage.cached_content_token_count
610
+ ?? 0;
611
+ }
612
+
613
+ static extractCacheWriteTokens(usage = {}) {
614
+ return usage.input_tokens_details?.cache_write_tokens
615
+ ?? usage.prompt_tokens_details?.cache_write_tokens
616
+ ?? usage.cache_creation_input_tokens
617
+ ?? usage.cache_write_input_tokens
618
+ ?? usage.cacheWriteTokenCount
619
+ ?? usage.cache_write_token_count
620
+ ?? 0;
255
621
  }
256
622
 
257
623
  static formatInputSummary(messages, system, debug = 2) {
@@ -385,12 +751,18 @@ class ModelMix {
385
751
  if (mix.openrouter) this.attach('openai/gpt-oss-120b:free', new MixOpenRouter({ options, config }));
386
752
  return this;
387
753
  }
388
- fable5({ options = {}, config = {} } = {}) {
754
+ fable50({ options = {}, config = {} } = {}) {
389
755
  return this.attach('claude-fable-5', new MixAnthropic({ options, config }));
390
756
  }
391
- opus5({ options = {}, config = {} } = {}) {
757
+ fable5(args = {}) {
758
+ return this.fable50(args);
759
+ }
760
+ opus50({ options = {}, config = {} } = {}) {
392
761
  return this.attach('claude-opus-5', new MixAnthropic({ options, config }));
393
762
  }
763
+ opus5(args = {}) {
764
+ return this.opus50(args);
765
+ }
394
766
  opus48({ options = {}, config = {} } = {}) {
395
767
  return this.attach('claude-opus-4-8', new MixAnthropic({ options, config }));
396
768
  }
@@ -400,9 +772,12 @@ class ModelMix {
400
772
  opus46({ options = {}, config = {} } = {}) {
401
773
  return this.attach('claude-opus-4-6', new MixAnthropic({ options, config }));
402
774
  }
403
- sonnet5({ options = {}, config = {} } = {}) {
775
+ sonnet50({ options = {}, config = {} } = {}) {
404
776
  return this.attach('claude-sonnet-5', new MixAnthropic({ options, config }));
405
777
  }
778
+ sonnet5(args = {}) {
779
+ return this.sonnet50(args);
780
+ }
406
781
  sonnet46({ options = {}, config = {} } = {}) {
407
782
  return this.attach('claude-sonnet-4-6', new MixAnthropic({ options, config }));
408
783
  }
@@ -597,34 +972,54 @@ class ModelMix {
597
972
  return this;
598
973
  }
599
974
 
600
- addText(text, { role = "user" } = {}) {
975
+ addText(text, { role = "user", cache } = {}) {
976
+ return this._addText(text, {
977
+ role,
978
+ cache: normalizeContentCache(cache),
979
+ template: { source: text, filename: null }
980
+ });
981
+ }
982
+
983
+ _addText(text, { role = "user", cache, template = null } = {}) {
601
984
  const content = [{
602
985
  type: "text",
603
- text
986
+ text,
987
+ ...(cache !== undefined && { cache })
604
988
  }];
605
989
 
990
+ if (template) {
991
+ this.messageTemplates.set(content[0], template);
992
+ }
606
993
  this.messages.push({ role, content });
607
994
  return this;
608
995
  }
609
996
 
610
- addTextFromFile(filePath, { role = "user" } = {}) {
611
- const content = this.readFile(filePath);
612
- this.addText(content, { role });
613
- return this;
997
+ addTextFromFile(filePath, { role = "user", cache } = {}) {
998
+ const filename = path.resolve(filePath);
999
+ const content = this.readFile(filename);
1000
+ return this._addText(content, {
1001
+ role,
1002
+ cache: normalizeContentCache(cache),
1003
+ template: { source: content, filename }
1004
+ });
614
1005
  }
615
1006
 
616
1007
  setSystem(text) {
617
1008
  this.config.system = text;
1009
+ this.systemTemplate = { source: text, filename: null };
618
1010
  return this;
619
1011
  }
620
1012
 
621
1013
  setSystemFromFile(filePath) {
622
- const content = this.readFile(filePath);
623
- this.setSystem(content);
1014
+ const filename = path.resolve(filePath);
1015
+ const content = this.readFile(filename);
1016
+ this.config.system = content;
1017
+ this.systemTemplate = { source: content, filename };
624
1018
  return this;
625
1019
  }
626
1020
 
627
- addImageFromBuffer(buffer, { role = "user" } = {}) {
1021
+ addImageFromBuffer(buffer, { role = "user", cache } = {}) {
1022
+ const contentCache = normalizeContentCache(cache);
628
1023
  this.messages.push({
629
1024
  role,
630
1025
  content: [{
@@ -632,19 +1027,21 @@ class ModelMix {
632
1027
  source: {
633
1028
  type: "buffer",
634
1029
  data: buffer
635
- }
1030
+ },
1031
+ ...(contentCache !== undefined && { cache: contentCache })
636
1032
  }]
637
1033
  });
638
1034
  return this;
639
1035
  }
640
1036
 
641
- addImage(filePath, { role = "user" } = {}) {
1037
+ addImage(filePath, { role = "user", cache } = {}) {
642
1038
  const absolutePath = path.resolve(filePath);
643
1039
 
644
1040
  if (!fs.existsSync(absolutePath)) {
645
1041
  throw new Error(`Image file not found: ${filePath}`);
646
1042
  }
647
1043
 
1044
+ const contentCache = normalizeContentCache(cache);
648
1045
  this.messages.push({
649
1046
  role,
650
1047
  content: [{
@@ -652,13 +1049,14 @@ class ModelMix {
652
1049
  source: {
653
1050
  type: "file",
654
1051
  data: filePath
655
- }
1052
+ },
1053
+ ...(contentCache !== undefined && { cache: contentCache })
656
1054
  }]
657
1055
  });
658
1056
  return this;
659
1057
  }
660
1058
 
661
- addImageFromUrl(url, { role = "user" } = {}) {
1059
+ addImageFromUrl(url, { role = "user", cache } = {}) {
662
1060
  let source;
663
1061
  if (url.startsWith('data:')) {
664
1062
  // Parse data URL: data:image/jpeg;base64,/9j/4AAQ...
@@ -679,11 +1077,13 @@ class ModelMix {
679
1077
  };
680
1078
  }
681
1079
 
1080
+ const contentCache = normalizeContentCache(cache);
682
1081
  this.messages.push({
683
1082
  role,
684
1083
  content: [{
685
1084
  type: "image",
686
- source
1085
+ source,
1086
+ ...(contentCache !== undefined && { cache: contentCache })
687
1087
  }]
688
1088
  });
689
1089
 
@@ -732,7 +1132,7 @@ class ModelMix {
732
1132
 
733
1133
  // Update the content with processed image
734
1134
  message.content[j] = {
735
- type: "image",
1135
+ ...content,
736
1136
  source: {
737
1137
  type: "base64",
738
1138
  media_type: mimeType,
@@ -771,27 +1171,23 @@ class ModelMix {
771
1171
  stream: false,
772
1172
  }
773
1173
 
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
- }
1174
+ let config = {};
1175
+ let systemSuffix = '';
780
1176
 
781
1177
  if (schemaExample) {
782
1178
  config.schema = generateJsonSchema(schemaExample, schemaDescription);
783
1179
 
784
1180
  if (addSchema) {
785
- config.system += "\n\nOutput JSON Schema: \n```\n" + JSON.stringify(config.schema) + "\n```";
1181
+ systemSuffix += "\n\nOutput JSON Schema: \n```\n" + JSON.stringify(config.schema) + "\n```";
786
1182
  }
787
1183
  if (addExample) {
788
- config.system += "\n\nOutput JSON Example: \n```\n" + JSON.stringify(schemaExample) + "\n```";
1184
+ systemSuffix += "\n\nOutput JSON Example: \n```\n" + JSON.stringify(schemaExample) + "\n```";
789
1185
  }
790
1186
  if (addNote) {
791
- config.system += "\n\nOutput JSON Escape: double quotes, backslashes, and control characters inside JSON strings.\nEnsure the output contains no comments.";
1187
+ systemSuffix += "\n\nOutput JSON Escape: double quotes, backslashes, and control characters inside JSON strings.\nEnsure the output contains no comments.";
792
1188
  }
793
1189
  }
794
- const { message } = await this.execute({ options, config });
1190
+ const { message } = await this.execute({ options, config, systemSuffix });
795
1191
  const parsed = JSON.parse(this._extractBlock(message));
796
1192
  return isArrayWrap ? parsed.out : parsed;
797
1193
  }
@@ -802,17 +1198,13 @@ class ModelMix {
802
1198
  }
803
1199
 
804
1200
  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 });
1201
+ const systemSuffix = addSystemExtra
1202
+ ? "\nReturn the result of the task between triple backtick block code tags ```"
1203
+ : '';
1204
+ const { message } = await this.execute({
1205
+ options: { stream: false },
1206
+ systemSuffix
1207
+ });
816
1208
  return this._extractBlock(message);
817
1209
  }
818
1210
 
@@ -826,22 +1218,52 @@ class ModelMix {
826
1218
  }
827
1219
 
828
1220
  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
- }
836
- return this;
1221
+ const content = this.readFile(filePath);
1222
+ return this.replace({ [key]: content });
837
1223
  }
838
1224
 
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("");
1225
+ _choiceRandom() {
1226
+ return Math.random();
1227
+ }
1228
+
1229
+ _renderTemplate(
1230
+ source,
1231
+ { filename = null, label = 'template' } = {},
1232
+ renderContext = createTemplateRenderContext(() => this._choiceRandom())
1233
+ ) {
1234
+ if (typeof source !== 'string') {
1235
+ throw new TypeError(`${label} source must be a string.`);
1236
+ }
1237
+
1238
+ try {
1239
+ const template = preprocessChoiceDirectives(source, { filename, label });
1240
+ const data = { ...(this.config.replace || {}), $mix: renderContext.helpers };
1241
+ return ejs.render(template, data, {
1242
+ ...(filename && { filename }),
1243
+ async: false,
1244
+ cache: false,
1245
+ compileDebug: true,
1246
+ unsafePrototypeLocals: false,
1247
+ includer: (originalPath, resolvedFilename) => {
1248
+ if (!resolvedFilename) {
1249
+ throw new Error(`Could not find the include file "${originalPath}"`);
1250
+ }
1251
+ const includedSource = fs.readFileSync(resolvedFilename, 'utf8').replace(/^\uFEFF/, '');
1252
+ return {
1253
+ filename: resolvedFilename,
1254
+ template: preprocessChoiceDirectives(includedSource, {
1255
+ filename: resolvedFilename,
1256
+ label: 'included template'
1257
+ })
1258
+ };
1259
+ }
1260
+ });
1261
+ } catch (error) {
1262
+ const location = filename ? ` ${filename}` : '';
1263
+ const renderError = new Error(`Failed to render ${label}${location}: ${error.message}`);
1264
+ renderError.cause = error;
1265
+ throw renderError;
843
1266
  }
844
- return input;
845
1267
  }
846
1268
 
847
1269
  static hasToolInteraction(message) {
@@ -873,37 +1295,56 @@ class ModelMix {
873
1295
  }, []);
874
1296
  }
875
1297
 
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);
1298
+ _renderMessageSnapshot(messages, renderContext) {
1299
+ return messages.map(message => ({
1300
+ ...message,
1301
+ content: Array.isArray(message.content)
1302
+ ? message.content.map(content => {
1303
+ if (!content || typeof content !== 'object') return content;
1304
+
1305
+ const snapshotContent = { ...content };
1306
+ const template = content.type === 'text'
1307
+ ? this.messageTemplates.get(content)
1308
+ : null;
1309
+ if (!template) return snapshotContent;
1310
+
1311
+ let rendered = renderContext.renderedMessages.get(content)?.rendered;
1312
+ if (rendered === undefined) {
1313
+ rendered = this._renderTemplate(template.source, {
1314
+ filename: template.filename,
1315
+ label: 'message template'
1316
+ }, renderContext);
1317
+ renderContext.renderedMessages.set(content, { rendered, template });
886
1318
  }
887
- return content;
888
- });
889
- }
890
- return message;
891
- });
1319
+ snapshotContent.text = rendered;
1320
+ return snapshotContent;
1321
+ })
1322
+ : message.content
1323
+ }));
1324
+ }
1325
+
1326
+ _commitTemplateRenderContext(renderContext) {
1327
+ for (const [content, { rendered, template }] of renderContext.renderedMessages) {
1328
+ if (this.messageTemplates.get(content) !== template) continue;
1329
+ content.text = rendered;
1330
+ this.messageTemplates.delete(content);
1331
+ }
892
1332
  }
893
1333
 
894
- async prepareMessages() {
1334
+ async prepareMessages(renderContext = createTemplateRenderContext(() => this._choiceRandom())) {
895
1335
  await this.processImages();
896
- this.applyTemplate();
1336
+
1337
+ let messages = this.messages;
897
1338
 
898
1339
  // Smart message slicing based on max_history:
899
1340
  // 0 = no history (stateless), N = keep last N messages, -1 = unlimited
900
1341
  if (this.config.max_history > 0) {
901
- let sliceStart = Math.max(0, this.messages.length - this.config.max_history);
1342
+ let sliceStart = Math.max(0, messages.length - this.config.max_history);
902
1343
 
903
1344
  // If we're slicing into the middle of a tool interaction,
904
1345
  // 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];
1346
+ while (sliceStart > 0 && sliceStart < messages.length) {
1347
+ const msg = messages[sliceStart];
907
1348
  if (ModelMix.hasToolInteraction(msg)) {
908
1349
  sliceStart--;
909
1350
  } else {
@@ -911,13 +1352,13 @@ class ModelMix {
911
1352
  }
912
1353
  }
913
1354
 
914
- this.messages = this.messages.slice(sliceStart);
1355
+ this.messages = messages.slice(sliceStart);
1356
+ messages = this.messages;
915
1357
  }
916
1358
  // max_history = -1: unlimited, no slicing
917
1359
  // max_history = 0: no history, messages only contain what was added since last call
918
1360
 
919
- this.messages = this.groupByRoles(this.messages);
920
- this.options.messages = this.messages;
1361
+ return this.groupByRoles(this._renderMessageSnapshot(messages, renderContext));
921
1362
  }
922
1363
 
923
1364
  readFile(filePath, { encoding = 'utf8' } = {}) {
@@ -935,15 +1376,30 @@ class ModelMix {
935
1376
  }
936
1377
  }
937
1378
 
938
- async execute({ config = {}, options = {} } = {}) {
1379
+ _resolveSystemTemplate(config, providerConfig) {
1380
+ if (Object.prototype.hasOwnProperty.call(config, 'system')) {
1381
+ return { source: config.system, filename: null };
1382
+ }
1383
+ if (Object.prototype.hasOwnProperty.call(providerConfig, 'system')) {
1384
+ return { source: providerConfig.system, filename: null };
1385
+ }
1386
+ if (this.config.system !== this.systemTemplate.source) {
1387
+ return { source: this.config.system, filename: null };
1388
+ }
1389
+ return this.systemTemplate;
1390
+ }
1391
+
1392
+ async execute({ config = {}, options = {}, systemSuffix = '', _templateContext = null } = {}) {
939
1393
  if (!this.models || this.models.length === 0) {
940
1394
  throw new Error("No models specified. Use methods like .gpt5(), .sonnet46() first.");
941
1395
  }
942
1396
 
943
- return this.limiter.schedule(async () => {
944
- await this.prepareMessages();
1397
+ const isRootExecution = _templateContext === null;
1398
+ const templateContext = _templateContext || createTemplateRenderContext(() => this._choiceRandom());
1399
+ const execution = this.limiter.schedule(async () => {
1400
+ const preparedMessages = await this.prepareMessages(templateContext);
945
1401
 
946
- if (this.messages.length === 0) {
1402
+ if (preparedMessages.length === 0) {
947
1403
  throw new Error("No user messages have been added. Use addText(prompt), addTextFromFile(filePath), addImage(filePath), or addImageFromUrl(url) to add a prompt.");
948
1404
  }
949
1405
 
@@ -978,6 +1434,7 @@ class ModelMix {
978
1434
  // Create clean copies for each provider to avoid contamination
979
1435
  const currentOptions = {
980
1436
  ...this.options,
1437
+ messages: preparedMessages,
981
1438
  ...providerInstance.options,
982
1439
  ...optionsTools,
983
1440
  ...options,
@@ -994,6 +1451,18 @@ class ModelMix {
994
1451
  ...(config.retry || {})
995
1452
  }
996
1453
  };
1454
+ const systemTemplate = this._resolveSystemTemplate(config, providerInstance.config);
1455
+ const systemCacheKey = JSON.stringify([systemTemplate.filename, systemTemplate.source]);
1456
+ if (!templateContext.renderedSystems.has(systemCacheKey)) {
1457
+ templateContext.renderedSystems.set(
1458
+ systemCacheKey,
1459
+ this._renderTemplate(systemTemplate.source, {
1460
+ filename: systemTemplate.filename,
1461
+ label: 'system template'
1462
+ }, templateContext)
1463
+ );
1464
+ }
1465
+ currentConfig.system = templateContext.renderedSystems.get(systemCacheKey) + systemSuffix;
997
1466
 
998
1467
  // Grok 4.20 alias → reasoning / non-reasoning from unified effort
999
1468
  const resolvedModelKey = resolveGrok420ModelKey(
@@ -1018,7 +1487,7 @@ class ModelMix {
1018
1487
  const header = `\n${prefix} [${providerName}:${resolvedModelKey}] #${originalIndex + 1}${suffix}`;
1019
1488
 
1020
1489
  if (currentConfig.debug >= 2) {
1021
- console.log(`${header}\n${ModelMix.formatInputSummary(this.messages, currentConfig.system, currentConfig.debug)}`);
1490
+ console.log(`${header}\n${ModelMix.formatInputSummary(preparedMessages, currentConfig.system, currentConfig.debug)}`);
1022
1491
  } else {
1023
1492
  console.log(header);
1024
1493
  }
@@ -1072,7 +1541,16 @@ class ModelMix {
1072
1541
  const elapsedMs = Date.now() - startTime;
1073
1542
 
1074
1543
  if (result.tokens) {
1075
- result.tokens.cost = ModelMix.calculateCost(resolvedModelKey, result.tokens);
1544
+ const normalizedTokens = ModelMix.normalizeTokenUsage(result.tokens);
1545
+ const costBreakdown = ModelMix.calculateCostBreakdown(resolvedModelKey, normalizedTokens);
1546
+ const cacheMetrics = ModelMix.calculateCacheMetrics(resolvedModelKey, normalizedTokens);
1547
+ result.tokens = {
1548
+ ...result.tokens,
1549
+ ...normalizedTokens,
1550
+ ...cacheMetrics,
1551
+ cost: MODEL_PRICING[resolvedModelKey] ? costBreakdown.total : 0,
1552
+ costBreakdown
1553
+ };
1076
1554
  const elapsedSec = elapsedMs / 1000;
1077
1555
  result.tokens.speed = elapsedSec > 0 ? Math.round(result.tokens.output / elapsedSec) : 0;
1078
1556
  }
@@ -1091,7 +1569,7 @@ class ModelMix {
1091
1569
  }]
1092
1570
  });
1093
1571
  } else {
1094
- this.addText(result.message, { role: "assistant" });
1572
+ this._addText(result.message, { role: "assistant" });
1095
1573
  }
1096
1574
  }
1097
1575
 
@@ -1109,7 +1587,7 @@ class ModelMix {
1109
1587
  });
1110
1588
  }
1111
1589
 
1112
- return this.execute({ options, config });
1590
+ return this.execute({ options, config, systemSuffix, _templateContext: templateContext });
1113
1591
  }
1114
1592
 
1115
1593
  // debug level 1: Just success indicator
@@ -1171,7 +1649,7 @@ class ModelMix {
1171
1649
  }]
1172
1650
  });
1173
1651
  } else {
1174
- this.addText(result.message, { role: "assistant" });
1652
+ this._addText(result.message, { role: "assistant" });
1175
1653
  }
1176
1654
  }
1177
1655
 
@@ -1197,6 +1675,12 @@ class ModelMix {
1197
1675
  log.error("Fallback logic completed without success or throwing the final error.");
1198
1676
  throw lastError || new Error("Failed to get response from any model, and no specific error was caught.");
1199
1677
  });
1678
+
1679
+ if (!isRootExecution) return execution;
1680
+
1681
+ const result = await execution;
1682
+ this._commitTemplateRenderContext(templateContext);
1683
+ return result;
1200
1684
  }
1201
1685
 
1202
1686
  async processToolCalls(toolCalls) {
@@ -1397,6 +1881,13 @@ class MixCustom {
1397
1881
  return MixOpenAI.convertMessages(messages, config);
1398
1882
  }
1399
1883
 
1884
+ sanitizeCacheOptions(options) {
1885
+ delete options.cache_control;
1886
+ delete options.prompt_cache_key;
1887
+ delete options.prompt_cache_options;
1888
+ delete options.prompt_cache_retention;
1889
+ }
1890
+
1400
1891
  static stripContentTypeHeader(headers = {}) {
1401
1892
  return stripContentTypeHeader(headers);
1402
1893
  }
@@ -1411,6 +1902,7 @@ class MixCustom {
1411
1902
 
1412
1903
  async create({ config = {}, options = {} } = {}) {
1413
1904
  try {
1905
+ this.sanitizeCacheOptions(options);
1414
1906
  if (Array.isArray(options.messages)) {
1415
1907
  options.messages = this.convertMessages(options.messages, config);
1416
1908
  }
@@ -1422,9 +1914,7 @@ class MixCustom {
1422
1914
  console.log('\n[REQUEST DETAILS]');
1423
1915
 
1424
1916
  console.log('\n[CONFIG]');
1425
- const configToLog = { ...config };
1426
- delete configToLog.debug;
1427
- console.log(ModelMix.formatJSON(configToLog));
1917
+ console.log(ModelMix.formatJSON(configForDebug(config)));
1428
1918
 
1429
1919
  console.log('\n[OPTIONS]');
1430
1920
  console.log(ModelMix.formatJSON(request.options));
@@ -1444,11 +1934,11 @@ class MixCustom {
1444
1934
  }));
1445
1935
  }
1446
1936
  } catch (error) {
1447
- throw this.handleError(error, { config, options });
1937
+ throw this.handleError(error);
1448
1938
  }
1449
1939
  }
1450
1940
 
1451
- handleError(error, { config, options }) {
1941
+ handleError(error) {
1452
1942
  let errorMessage = 'An error occurred in MixCustom';
1453
1943
  let statusCode = null;
1454
1944
  let errorDetails = null;
@@ -1462,12 +1952,10 @@ class MixCustom {
1462
1952
  }
1463
1953
 
1464
1954
  const formattedError = {
1465
- message: errorMessage,
1955
+ message: redactSecret(errorMessage, this.config.apiKey),
1466
1956
  statusCode,
1467
- details: errorDetails,
1468
- stack: error.stack,
1469
- config: config,
1470
- options: options
1957
+ details: redactSecret(errorDetails, this.config.apiKey),
1958
+ stack: redactSecret(error.stack, this.config.apiKey)
1471
1959
  };
1472
1960
 
1473
1961
  return formattedError;
@@ -1586,19 +2074,15 @@ class MixCustom {
1586
2074
  static extractTokens(data) {
1587
2075
  // OpenAI/Groq/Together/Lambda/Cerebras/Fireworks format
1588
2076
  if (data.usage) {
1589
- return {
2077
+ return ModelMix.normalizeTokenUsage({
1590
2078
  input: data.usage.prompt_tokens || 0,
1591
2079
  output: data.usage.completion_tokens || 0,
1592
- total: data.usage.total_tokens || 0,
1593
- cached: ModelMix.extractCacheTokens(data.usage)
1594
- };
2080
+ total: data.usage.total_tokens,
2081
+ cached: ModelMix.extractCacheTokens(data.usage),
2082
+ cacheWrite: ModelMix.extractCacheWriteTokens(data.usage)
2083
+ });
1595
2084
  }
1596
- return {
1597
- input: 0,
1598
- output: 0,
1599
- total: 0,
1600
- cached: 0
1601
- };
2085
+ return ModelMix.normalizeTokenUsage();
1602
2086
  }
1603
2087
 
1604
2088
  processResponse(response) {
@@ -1617,6 +2101,11 @@ class MixCustom {
1617
2101
  }
1618
2102
 
1619
2103
  class MixOpenAI extends MixCustom {
2104
+ sanitizeCacheOptions(options) {
2105
+ delete options.cache_control;
2106
+ delete options.prompt_cache_options;
2107
+ }
2108
+
1620
2109
  getDefaultConfig(customConfig) {
1621
2110
 
1622
2111
  if (!process.env.OPENAI_API_KEY) {
@@ -1690,22 +2179,26 @@ class MixOpenAI extends MixCustom {
1690
2179
  continue;
1691
2180
  }
1692
2181
 
2182
+ let convertedMessage = { ...message };
1693
2183
  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
- });
2184
+ convertedMessage = {
2185
+ ...message,
2186
+ content: message.content.filter(content => content !== null && content !== undefined).map(content => {
2187
+ if (content && content.type === 'image') {
2188
+ const { media_type, data } = content.source;
2189
+ return {
2190
+ type: 'image_url',
2191
+ image_url: {
2192
+ url: `data:${media_type};base64,${data}`
2193
+ }
2194
+ };
2195
+ }
2196
+ return stripContentCacheMetadata(content);
2197
+ })
2198
+ };
1706
2199
  }
1707
2200
 
1708
- results.push(message);
2201
+ results.push(convertedMessage);
1709
2202
  }
1710
2203
 
1711
2204
  return results;
@@ -1765,10 +2258,14 @@ class MixOpenAIResponses extends MixOpenAI {
1765
2258
  }
1766
2259
 
1767
2260
  static buildResponsesRequest(options = {}, config = {}) {
1768
- const input = MixOpenAIResponses.messagesToResponsesInput(options.messages);
2261
+ const isGPT56 = typeof options.model === 'string' && options.model.startsWith('gpt-5.6');
2262
+ const input = MixOpenAIResponses.messagesToResponsesInput(options.messages, {
2263
+ translateNeutralCache: isGPT56
2264
+ });
1769
2265
  if (config.system) {
1770
2266
  input.unshift({ role: 'developer', content: [{ type: 'input_text', text: config.system }] });
1771
2267
  }
2268
+ MixOpenAIResponses.validatePromptCaching(options, input);
1772
2269
  const request = {
1773
2270
  model: options.model,
1774
2271
  input,
@@ -1812,10 +2309,52 @@ class MixOpenAIResponses extends MixOpenAI {
1812
2309
  if (options.user !== undefined) request.user = options.user;
1813
2310
  if (options.prompt_cache_key !== undefined) request.prompt_cache_key = options.prompt_cache_key;
1814
2311
  if (options.prompt_cache_retention !== undefined) request.prompt_cache_retention = options.prompt_cache_retention;
2312
+ if (options.prompt_cache_options !== undefined) request.prompt_cache_options = options.prompt_cache_options;
1815
2313
 
1816
2314
  return request;
1817
2315
  }
1818
2316
 
2317
+ static validatePromptCaching(options, input) {
2318
+ const isGPT56 = typeof options.model === 'string' && options.model.startsWith('gpt-5.6');
2319
+ const cacheOptions = options.prompt_cache_options;
2320
+ const breakpoints = input.flatMap(message => Array.isArray(message.content)
2321
+ ? message.content
2322
+ .filter(block => block?.prompt_cache_breakpoint !== undefined)
2323
+ .map(block => block.prompt_cache_breakpoint)
2324
+ : []);
2325
+
2326
+ if (isGPT56 && options.prompt_cache_retention !== undefined) {
2327
+ throw new Error('GPT-5.6 does not support prompt_cache_retention; use prompt_cache_options.ttl instead.');
2328
+ }
2329
+ if (!isGPT56 && cacheOptions !== undefined) {
2330
+ throw new Error('prompt_cache_options is only supported by GPT-5.6 models.');
2331
+ }
2332
+ if (!isGPT56 && breakpoints.length > 0) {
2333
+ throw new Error('prompt_cache_breakpoint is only supported by GPT-5.6 models.');
2334
+ }
2335
+ if (cacheOptions !== undefined) {
2336
+ if (!isPlainObject(cacheOptions)) {
2337
+ throw new TypeError('prompt_cache_options must be a plain non-null object.');
2338
+ }
2339
+ if (cacheOptions.mode !== undefined
2340
+ && cacheOptions.mode !== 'implicit'
2341
+ && cacheOptions.mode !== 'explicit') {
2342
+ throw new TypeError('prompt_cache_options.mode must be "implicit" or "explicit".');
2343
+ }
2344
+ if (cacheOptions.ttl !== undefined && cacheOptions.ttl !== '30m') {
2345
+ throw new TypeError('prompt_cache_options.ttl must be "30m".');
2346
+ }
2347
+ }
2348
+ for (const breakpoint of breakpoints) {
2349
+ if (!isPlainObject(breakpoint)) {
2350
+ throw new TypeError('prompt_cache_breakpoint must be a plain non-null object.');
2351
+ }
2352
+ if (breakpoint.mode !== 'explicit') {
2353
+ throw new TypeError('prompt_cache_breakpoint mode must be "explicit".');
2354
+ }
2355
+ }
2356
+ }
2357
+
1819
2358
  static processResponsesResponse(response) {
1820
2359
  const message = MixOpenAIResponses.extractResponsesMessage(response.data);
1821
2360
  return {
@@ -1829,19 +2368,15 @@ class MixOpenAIResponses extends MixOpenAI {
1829
2368
 
1830
2369
  static extractResponsesTokens(data) {
1831
2370
  if (data.usage) {
1832
- return {
2371
+ return ModelMix.normalizeTokenUsage({
1833
2372
  input: data.usage.input_tokens || 0,
1834
2373
  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
- };
2374
+ total: data.usage.total_tokens,
2375
+ cached: ModelMix.extractCacheTokens(data.usage),
2376
+ cacheWrite: ModelMix.extractCacheWriteTokens(data.usage)
2377
+ });
1838
2378
  }
1839
- return {
1840
- input: 0,
1841
- output: 0,
1842
- total: 0,
1843
- cached: 0
1844
- };
2379
+ return ModelMix.normalizeTokenUsage();
1845
2380
  }
1846
2381
 
1847
2382
  static extractResponsesMessage(data) {
@@ -1855,27 +2390,70 @@ class MixOpenAIResponses extends MixOpenAI {
1855
2390
  .trim();
1856
2391
  }
1857
2392
 
1858
- static messagesToResponsesInput(messages = []) {
2393
+ static messagesToResponsesInput(messages = [], { translateNeutralCache = false } = {}) {
1859
2394
  const mapped = [];
1860
2395
 
1861
2396
  for (const message of messages) {
1862
2397
  if (!message || !message.role) continue;
1863
2398
  if (message.tool_calls || message.role === 'tool') continue;
1864
2399
 
1865
- let text = '';
2400
+ const content = [];
2401
+ const isAssistant = message.role === 'assistant';
2402
+ const textType = isAssistant ? 'output_text' : 'input_text';
1866
2403
  if (typeof message.content === 'string') {
1867
- text = message.content;
2404
+ if (message.content) content.push({ type: textType, text: message.content });
1868
2405
  } 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');
2406
+ for (const item of message.content) {
2407
+ if (!item || typeof item !== 'object') continue;
2408
+ const neutralCache = item.cache !== undefined
2409
+ ? normalizeContentCache(item.cache)
2410
+ : undefined;
2411
+ const promptCacheBreakpoint = item.prompt_cache_breakpoint !== undefined
2412
+ ? item.prompt_cache_breakpoint
2413
+ : (translateNeutralCache && neutralCache?.breakpoint
2414
+ ? { mode: 'explicit' }
2415
+ : undefined);
2416
+ const breakpoint = !isAssistant && promptCacheBreakpoint !== undefined
2417
+ ? { prompt_cache_breakpoint: promptCacheBreakpoint }
2418
+ : {};
2419
+
2420
+ if ((item.type === 'text' || item.type === 'input_text' || item.type === 'output_text')
2421
+ && typeof item.text === 'string') {
2422
+ content.push({ type: textType, text: item.text, ...breakpoint });
2423
+ continue;
2424
+ }
2425
+ if (item.type === 'image' && item.source) {
2426
+ let imageUrl;
2427
+ if (item.source.type === 'base64') {
2428
+ if (!item.source.media_type || typeof item.source.data !== 'string') {
2429
+ throw new TypeError('Responses base64 images require source.media_type and string source.data.');
2430
+ }
2431
+ imageUrl = `data:${item.source.media_type};base64,${item.source.data}`;
2432
+ } else if (item.source.type === 'url' && typeof item.source.data === 'string') {
2433
+ imageUrl = item.source.data;
2434
+ } else {
2435
+ throw new TypeError('Responses images must be processed to base64 or use a URL source.');
2436
+ }
2437
+ content.push({ type: 'input_image', image_url: imageUrl, ...breakpoint });
2438
+ continue;
2439
+ }
2440
+ if (item.type === 'image_url' && typeof item.image_url?.url === 'string') {
2441
+ content.push({ type: 'input_image', image_url: item.image_url.url, ...breakpoint });
2442
+ continue;
2443
+ }
2444
+ if (item.type === 'input_image' || item.type === 'input_file') {
2445
+ content.push({
2446
+ ...stripContentCacheMetadata(item),
2447
+ ...breakpoint
2448
+ });
2449
+ }
2450
+ }
1873
2451
  }
1874
2452
 
1875
- if (!text) continue;
2453
+ if (content.length === 0) continue;
1876
2454
  mapped.push({
1877
2455
  role: message.role,
1878
- content: [{ type: 'input_text', text }]
2456
+ content
1879
2457
  });
1880
2458
  }
1881
2459
 
@@ -1928,9 +2506,7 @@ class MixOpenAIWebSocket extends MixOpenAIResponses {
1928
2506
  reject({
1929
2507
  message: `Realtime WebSocket timed out after ${timeoutMs}ms`,
1930
2508
  statusCode: null,
1931
- details: null,
1932
- config: mergedConfig,
1933
- options
2509
+ details: null
1934
2510
  });
1935
2511
  }, timeoutMs);
1936
2512
 
@@ -2024,9 +2600,7 @@ class MixOpenAIWebSocket extends MixOpenAIResponses {
2024
2600
  reject({
2025
2601
  message: event.error?.message || 'Realtime WebSocket error',
2026
2602
  statusCode: null,
2027
- details: event.error || event,
2028
- config: mergedConfig,
2029
- options
2603
+ details: event.error || event
2030
2604
  });
2031
2605
  }
2032
2606
  });
@@ -2039,9 +2613,7 @@ class MixOpenAIWebSocket extends MixOpenAIResponses {
2039
2613
  message: error.message || 'Realtime WebSocket connection error',
2040
2614
  statusCode: null,
2041
2615
  details: null,
2042
- stack: error.stack,
2043
- config: mergedConfig,
2044
- options
2616
+ stack: error.stack
2045
2617
  });
2046
2618
  });
2047
2619
 
@@ -2052,9 +2624,7 @@ class MixOpenAIWebSocket extends MixOpenAIResponses {
2052
2624
  reject({
2053
2625
  message: 'Realtime WebSocket closed before response.done',
2054
2626
  statusCode: null,
2055
- details: null,
2056
- config: mergedConfig,
2057
- options
2627
+ details: null
2058
2628
  });
2059
2629
  });
2060
2630
  });
@@ -2155,6 +2725,23 @@ class MixKimi extends MixOpenAI {
2155
2725
 
2156
2726
  class MixAnthropic extends MixCustom {
2157
2727
 
2728
+ sanitizeCacheOptions(options) {
2729
+ delete options.prompt_cache_key;
2730
+ delete options.prompt_cache_options;
2731
+ delete options.prompt_cache_retention;
2732
+ }
2733
+
2734
+ static validateCacheControl(cacheControl) {
2735
+ if (!isPlainObject(cacheControl) || cacheControl.type !== 'ephemeral') {
2736
+ throw new TypeError('Anthropic cache_control must have type "ephemeral".');
2737
+ }
2738
+ if (cacheControl.ttl !== undefined
2739
+ && cacheControl.ttl !== '5m'
2740
+ && cacheControl.ttl !== '1h') {
2741
+ throw new TypeError('Anthropic cache_control.ttl must be "5m" or "1h".');
2742
+ }
2743
+ }
2744
+
2158
2745
  /**
2159
2746
  * Opus 4.7+ and Claude 5 family reject sampling params (temperature/top_p/top_k).
2160
2747
  * See: https://platform.claude.com/docs/en/about-claude/models/migration-guide
@@ -2200,10 +2787,20 @@ class MixAnthropic extends MixCustom {
2200
2787
  delete options.top_k;
2201
2788
  }
2202
2789
 
2790
+ const requestConfig = { ...config };
2791
+ if (hasNeutralCacheBreakpoint(options.messages)) {
2792
+ const contentCacheControl = options.cache_control ?? { type: 'ephemeral' };
2793
+ MixAnthropic.validateCacheControl(contentCacheControl);
2794
+ requestConfig._contentCacheControl = { ...contentCacheControl };
2795
+ delete options.cache_control;
2796
+ } else if (options.cache_control !== undefined) {
2797
+ MixAnthropic.validateCacheControl(options.cache_control);
2798
+ }
2799
+
2203
2800
  options.system = config.system;
2204
2801
 
2205
2802
  try {
2206
- return await super.create({ config, options });
2803
+ return await super.create({ config: requestConfig, options });
2207
2804
  } catch (error) {
2208
2805
  // Log the error details for debugging
2209
2806
  if (error.response && error.response.data) {
@@ -2275,20 +2872,37 @@ class MixAnthropic extends MixCustom {
2275
2872
 
2276
2873
  // Handle content conversion for other messages
2277
2874
  if (message.content && Array.isArray(message.content)) {
2278
- message.content = message.content.filter(content => content !== null && content !== undefined).map(content => {
2875
+ const content = message.content.filter(content => content !== null && content !== undefined).map(content => {
2876
+ const neutralCache = content?.cache !== undefined
2877
+ ? normalizeContentCache(content.cache)
2878
+ : undefined;
2879
+ if (neutralCache && content.cache_control !== undefined) {
2880
+ throw new TypeError('Use either cache or cache_control on an Anthropic content block, not both.');
2881
+ }
2882
+ let converted = content;
2279
2883
  if (content && content.type === 'function') {
2280
- return {
2884
+ converted = {
2281
2885
  type: 'tool_use',
2282
2886
  id: content.id,
2283
2887
  name: content.function.name,
2284
2888
  input: JSON.parse(content.function.arguments)
2285
- }
2889
+ };
2286
2890
  }
2287
- return content;
2891
+ const sanitized = stripContentCacheMetadata(converted);
2892
+ if (content.cache_control !== undefined) {
2893
+ MixAnthropic.validateCacheControl(content.cache_control);
2894
+ sanitized.cache_control = { ...content.cache_control };
2895
+ } else if (neutralCache?.breakpoint) {
2896
+ sanitized.cache_control = {
2897
+ ...(config?._contentCacheControl || { type: 'ephemeral' })
2898
+ };
2899
+ }
2900
+ return sanitized;
2288
2901
  });
2902
+ return { ...message, content };
2289
2903
  }
2290
2904
 
2291
- return message;
2905
+ return { ...message };
2292
2906
  });
2293
2907
  }
2294
2908
 
@@ -2370,19 +2984,26 @@ class MixAnthropic extends MixCustom {
2370
2984
  static extractTokens(data) {
2371
2985
  // Anthropic format
2372
2986
  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
- };
2987
+ const cached = ModelMix.extractCacheTokens(data.usage);
2988
+ const cacheWrite5m = data.usage.cache_creation?.ephemeral_5m_input_tokens ?? 0;
2989
+ const cacheWrite1h = data.usage.cache_creation?.ephemeral_1h_input_tokens ?? 0;
2990
+ const cacheWrite = Math.max(
2991
+ ModelMix.extractCacheWriteTokens(data.usage),
2992
+ cacheWrite5m + cacheWrite1h
2993
+ );
2994
+ const input = (data.usage.input_tokens || 0) + cached + cacheWrite;
2995
+ const output = data.usage.output_tokens || 0;
2996
+ return ModelMix.normalizeTokenUsage({
2997
+ input,
2998
+ output,
2999
+ total: input + output,
3000
+ cached,
3001
+ cacheWrite,
3002
+ cacheWrite5m,
3003
+ cacheWrite1h
3004
+ });
2379
3005
  }
2380
- return {
2381
- input: 0,
2382
- output: 0,
2383
- total: 0,
2384
- cached: 0
2385
- };
3006
+ return ModelMix.normalizeTokenUsage();
2386
3007
  }
2387
3008
 
2388
3009
  processResponse(response) {
@@ -2566,6 +3187,13 @@ class MixGrok extends MixOpenAI {
2566
3187
  ...customConfig
2567
3188
  });
2568
3189
  }
3190
+
3191
+ async create({ config = {}, options = {} } = {}) {
3192
+ if (options.model === GROK420_REASONING || options.model === GROK420_NON_REASONING) {
3193
+ delete options.reasoning_effort;
3194
+ }
3195
+ return super.create({ config, options });
3196
+ }
2569
3197
  }
2570
3198
 
2571
3199
  class MixLambda extends MixCustom {
@@ -2740,6 +3368,7 @@ class MixGoogle extends MixCustom {
2740
3368
  return super.getDefaultConfig({
2741
3369
  url: 'https://generativelanguage.googleapis.com/v1beta/models',
2742
3370
  apiKey: process.env.GEMINI_API_KEY,
3371
+ ...customConfig
2743
3372
  });
2744
3373
  }
2745
3374
 
@@ -2909,9 +3538,7 @@ class MixGoogle extends MixCustom {
2909
3538
  console.log('\n[REQUEST DETAILS - GOOGLE]');
2910
3539
 
2911
3540
  console.log('\n[CONFIG]');
2912
- const configToLog = { ...config };
2913
- delete configToLog.debug;
2914
- console.log(ModelMix.formatJSON(configToLog));
3541
+ console.log(ModelMix.formatJSON(configForDebug(config)));
2915
3542
 
2916
3543
  console.log('\n[PAYLOAD]');
2917
3544
  console.log(ModelMix.formatJSON(payload));
@@ -2927,7 +3554,7 @@ class MixGoogle extends MixCustom {
2927
3554
  }));
2928
3555
  }
2929
3556
  } catch (error) {
2930
- throw this.handleError(error, { config, options });
3557
+ throw this.handleError(error);
2931
3558
  }
2932
3559
  }
2933
3560
 
@@ -2965,19 +3592,15 @@ class MixGoogle extends MixCustom {
2965
3592
  static extractTokens(data) {
2966
3593
  // Google Gemini format
2967
3594
  if (data.usageMetadata) {
2968
- return {
3595
+ return ModelMix.normalizeTokenUsage({
2969
3596
  input: data.usageMetadata.promptTokenCount || 0,
2970
3597
  output: data.usageMetadata.candidatesTokenCount || 0,
2971
- total: data.usageMetadata.totalTokenCount || 0,
2972
- cached: ModelMix.extractCacheTokens(data.usageMetadata)
2973
- };
3598
+ total: data.usageMetadata.totalTokenCount,
3599
+ cached: ModelMix.extractCacheTokens(data.usageMetadata),
3600
+ cacheWrite: ModelMix.extractCacheWriteTokens(data.usageMetadata)
3601
+ });
2974
3602
  }
2975
- return {
2976
- input: 0,
2977
- output: 0,
2978
- total: 0,
2979
- cached: 0
2980
- };
3603
+ return ModelMix.normalizeTokenUsage();
2981
3604
  }
2982
3605
 
2983
3606
  static stripUnsupportedSchemaProps(schema) {
@@ -3024,4 +3647,4 @@ class MixGoogle extends MixCustom {
3024
3647
  }
3025
3648
  }
3026
3649
 
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 };
3650
+ 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 };