modelmix 5.0.6 → 5.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,168 @@
1
+ const { isPlainObject } = require('./object-utils');
2
+
3
+ function validateTemplateData(value) {
4
+ if (!isPlainObject(value)) {
5
+ throw new TypeError('Template data must be a plain non-null object.');
6
+ }
7
+ if (Object.prototype.hasOwnProperty.call(value, '$mix')) {
8
+ throw new TypeError('Template data key "$mix" is reserved.');
9
+ }
10
+ }
11
+
12
+ function validateTemplateDataKey(key) {
13
+ if (typeof key !== 'string' || key.length === 0) {
14
+ throw new TypeError('Template data key must be a non-empty string.');
15
+ }
16
+ if (key === '$mix') {
17
+ throw new TypeError('Template data key "$mix" is reserved.');
18
+ }
19
+ }
20
+
21
+ function templateLocation({ filename, label }, lineNumber) {
22
+ return `${filename || label} at line ${lineNumber}`;
23
+ }
24
+
25
+ function preprocessChoiceDirectives(source, { filename = null, label = 'template' } = {}) {
26
+ const parts = source.split(/(\r\n|\n|\r)/);
27
+ const blocks = [];
28
+
29
+ for (let index = 0; index < parts.length; index += 2) {
30
+ const line = parts[index];
31
+ const trimmed = line.trim();
32
+ const lineNumber = (index / 2) + 1;
33
+ const location = templateLocation({ filename, label }, lineNumber);
34
+ const newline = parts[index + 1] || '';
35
+
36
+ if (/^<%\s*choice\s*%>$/.test(trimmed)) {
37
+ const parent = blocks[blocks.length - 1];
38
+ if (parent && parent.optionCount === 0) {
39
+ throw new Error(`A nested choice must be inside an option (${location}).`);
40
+ }
41
+ blocks.push({ lineNumber, optionCount: 0, weighted: null });
42
+ parts[index] = '<% $mix.choice(option => { -%>';
43
+ continue;
44
+ }
45
+
46
+ const optionMatch = trimmed.match(/^<%\s*option(?:\s+(.+?))?\s*%>$/);
47
+ if (optionMatch) {
48
+ const block = blocks[blocks.length - 1];
49
+ if (!block) {
50
+ throw new Error(`Option directive must be inside a choice (${location}).`);
51
+ }
52
+
53
+ const weightText = optionMatch[1];
54
+ const weighted = weightText !== undefined;
55
+ if (block.weighted !== null && block.weighted !== weighted) {
56
+ throw new Error(`Choice options must either all have weights or all omit them (${location}).`);
57
+ }
58
+
59
+ let argument = '';
60
+ if (weighted) {
61
+ const weight = Number(weightText);
62
+ if (!Number.isFinite(weight) || weight <= 0) {
63
+ throw new Error(`Choice weight must be a positive finite number (${location}).`);
64
+ }
65
+ argument = `${weight}, `;
66
+ }
67
+
68
+ block.weighted = weighted;
69
+ parts[index] = `<% ${block.optionCount > 0 ? '}); ' : ''}option(${argument}() => { -%>`;
70
+ block.optionCount += 1;
71
+ continue;
72
+ }
73
+
74
+ if (/^<%\s*\/choice\s*%>$/.test(trimmed)) {
75
+ const block = blocks.pop();
76
+ if (!block) {
77
+ throw new Error(`Closing choice directive has no matching opening directive (${location}).`);
78
+ }
79
+ if (block.optionCount === 0) {
80
+ throw new Error(`Choice must contain at least one option (${location}).`);
81
+ }
82
+ parts[index] = '<% }); }); -%>';
83
+ continue;
84
+ }
85
+
86
+ if (/^<%\s*(?:choice|option|\/choice)(?:\s|%>)/.test(trimmed)) {
87
+ throw new Error(`Invalid choice directive (${location}).`);
88
+ }
89
+
90
+ const block = blocks[blocks.length - 1];
91
+ if (block && block.optionCount === 0) {
92
+ if (trimmed) {
93
+ throw new Error(`Choice content must be inside an option (${location}).`);
94
+ }
95
+ parts[index] = '<%# -%>';
96
+ }
97
+
98
+ if (newline) parts[index + 1] = newline;
99
+ }
100
+
101
+ if (blocks.length > 0) {
102
+ const block = blocks[blocks.length - 1];
103
+ throw new Error(`Unclosed choice directive (${templateLocation({ filename, label }, block.lineNumber)}).`);
104
+ }
105
+
106
+ return parts.join('');
107
+ }
108
+
109
+ function createTemplateRenderContext(random = Math.random) {
110
+ const choice = defineOptions => {
111
+ if (typeof defineOptions !== 'function') {
112
+ throw new TypeError('$mix.choice expects an option definition callback.');
113
+ }
114
+
115
+ const options = [];
116
+ let weighted = null;
117
+ const option = (weightOrRender, renderOption) => {
118
+ const hasWeight = renderOption !== undefined;
119
+ const weight = hasWeight ? weightOrRender : 1;
120
+ const render = hasWeight ? renderOption : weightOrRender;
121
+
122
+ if (weighted !== null && weighted !== hasWeight) {
123
+ throw new TypeError('$mix.choice options cannot mix weighted and unweighted forms.');
124
+ }
125
+ if (!Number.isFinite(weight) || weight <= 0) {
126
+ throw new TypeError('$mix.choice weights must be positive finite numbers.');
127
+ }
128
+ if (typeof render !== 'function') {
129
+ throw new TypeError('$mix.choice options require a render callback.');
130
+ }
131
+
132
+ weighted = hasWeight;
133
+ options.push({ weight, render });
134
+ };
135
+
136
+ defineOptions(option);
137
+ if (options.length === 0) {
138
+ throw new Error('$mix.choice requires at least one option.');
139
+ }
140
+
141
+ const totalWeight = options.reduce((sum, current) => sum + current.weight, 0);
142
+ if (!Number.isFinite(totalWeight)) {
143
+ throw new TypeError('$mix.choice total weight must be finite.');
144
+ }
145
+
146
+ let target = random() * totalWeight;
147
+ for (const current of options) {
148
+ target -= current.weight;
149
+ if (target < 0) return current.render();
150
+ }
151
+ return options[options.length - 1].render();
152
+ };
153
+
154
+ return {
155
+ helpers: Object.freeze({ choice }),
156
+ renderedTemplateData: new Map(),
157
+ renderedMessages: new Map(),
158
+ renderedSystems: new Map()
159
+ };
160
+ }
161
+
162
+ module.exports = {
163
+ validateTemplateData,
164
+ validateTemplateDataKey,
165
+ preprocessChoiceDirectives,
166
+ createTemplateRenderContext
167
+ };
168
+
@@ -0,0 +1,299 @@
1
+ const GPT56_LONG_CONTEXT_PRICING = Object.freeze({
2
+ inputThreshold: 272_000,
3
+ inputMultiplier: 2,
4
+ outputMultiplier: 1.5
5
+ });
6
+
7
+ const GROK46_LONG_CONTEXT_PRICING = Object.freeze({
8
+ inputThreshold: 200_000,
9
+ inputMultiplier: 2,
10
+ outputMultiplier: 2,
11
+ inclusive: true
12
+ });
13
+
14
+ function usesLongContextRates(pricing, inputTokens) {
15
+ const longContext = pricing.longContext;
16
+ if (!longContext) return false;
17
+ return longContext.inclusive
18
+ ? inputTokens >= longContext.inputThreshold
19
+ : inputTokens > longContext.inputThreshold;
20
+ }
21
+
22
+ const MODEL_PRICING = {
23
+ // OpenAI
24
+ 'gpt-realtime-mini': { input: 0.60, cachedInput: 0.06, output: 2.40 },
25
+ 'gpt-realtime': { input: 4.00, cachedInput: 0.40, output: 16.00 },
26
+ 'gpt-5.6-sol': { input: 5.00, cachedInput: 0.50, cacheWrite: 6.25, output: 30.00, longContext: GPT56_LONG_CONTEXT_PRICING },
27
+ 'gpt-5.6-terra': { input: 2.00, cachedInput: 0.20, cacheWrite: 2.50, output: 12.00, longContext: GPT56_LONG_CONTEXT_PRICING },
28
+ 'gpt-5.6-luna': { input: 0.20, cachedInput: 0.02, cacheWrite: 0.25, output: 1.20, longContext: GPT56_LONG_CONTEXT_PRICING },
29
+ 'gpt-5.5-pro': { input: 30.00, output: 180.00 },
30
+ 'gpt-5.5': { input: 5.00, cachedInput: 0.50, output: 30.00 },
31
+ 'gpt-5.4': { input: 2.50, cachedInput: 0.25, output: 15.00 },
32
+ 'gpt-5.4-pro': { input: 30.00, output: 180.00 },
33
+ 'gpt-5.4-mini': { input: 0.75, cachedInput: 0.075, output: 4.50 },
34
+ 'gpt-5.4-nano': { input: 0.20, cachedInput: 0.02, output: 1.25 },
35
+ 'gpt-5.3-codex': { input: 1.75, cachedInput: 0.175, output: 14.00 },
36
+ 'gpt-5.2': { input: 1.75, cachedInput: 0.175, output: 14.00 },
37
+ 'gpt-5.2-chat-latest': { input: 1.75, cachedInput: 0.175, output: 14.00 },
38
+ 'gpt-5.1': { input: 1.25, cachedInput: 0.125, output: 10.00 },
39
+ 'gpt-5': { input: 1.25, cachedInput: 0.125, output: 10.00 },
40
+ 'gpt-5-mini': { input: 0.25, cachedInput: 0.025, output: 2.00 },
41
+ 'gpt-5-nano': { input: 0.05, cachedInput: 0.005, output: 0.40 },
42
+ 'gpt-4.1': { input: 2.00, cachedInput: 0.50, output: 8.00 },
43
+ 'gpt-4.1-mini': { input: 0.40, cachedInput: 0.10, output: 1.60 },
44
+ 'gpt-4.1-nano': { input: 0.10, cachedInput: 0.025, output: 0.40 },
45
+ // gptOss (Together/Groq/Cerebras/OpenRouter)
46
+ 'openai/gpt-oss-120b': { input: 0.15, output: 0.60 },
47
+ 'gpt-oss-120b': { input: 0.15, output: 0.60 },
48
+ // Anthropic
49
+ 'claude-fable-5': { input: 10.00, cachedInput: 1.00, cacheWrite: 12.50, cacheWrite1h: 20.00, output: 50.00 },
50
+ 'claude-opus-5': { input: 5.00, cachedInput: 0.50, cacheWrite: 6.25, cacheWrite1h: 10.00, output: 25.00 },
51
+ 'claude-sonnet-5': { input: 3.00, cachedInput: 0.30, cacheWrite: 3.75, cacheWrite1h: 6.00, output: 15.00 },
52
+ 'claude-opus-4-8': { input: 5.00, cachedInput: 0.50, cacheWrite: 6.25, cacheWrite1h: 10.00, output: 25.00 },
53
+ 'claude-opus-4-7': { input: 5.00, cachedInput: 0.50, cacheWrite: 6.25, cacheWrite1h: 10.00, output: 25.00 },
54
+ 'claude-opus-4-6': { input: 5.00, cachedInput: 0.50, cacheWrite: 6.25, cacheWrite1h: 10.00, output: 25.00 },
55
+ 'claude-sonnet-4-6': { input: 3.00, cachedInput: 0.30, cacheWrite: 3.75, cacheWrite1h: 6.00, output: 15.00 },
56
+ 'claude-sonnet-4-5-20250929': { input: 3.00, cachedInput: 0.30, cacheWrite: 3.75, cacheWrite1h: 6.00, output: 15.00 },
57
+ 'claude-haiku-4-5-20251001': { input: 1.00, cachedInput: 0.10, cacheWrite: 1.25, cacheWrite1h: 2.00, output: 5.00 },
58
+ // Google
59
+ 'gemini-3.1-pro-preview': { input: 2.00, output: 12.00 },
60
+ 'gemini-3-pro-preview': { input: 2.00, output: 12.00 },
61
+ 'gemini-3-flash-preview': { input: 0.50, output: 3.00 },
62
+ 'gemini-3.7-flash': { input: 0.75, cachedInput: 0.075, output: 3.75 },
63
+ 'gemini-3.6-flash': { input: 0.75, cachedInput: 0.075, output: 3.75 },
64
+ 'gemini-3.5-flash': { input: 0.75, output: 4.50 },
65
+ 'gemini-3.5-flash-lite': { input: 0.30, output: 2.50 },
66
+ 'gemini-2.5-pro': { input: 1.25, output: 10.00 },
67
+ 'gemini-2.5-flash': { input: 0.30, output: 2.50 },
68
+ 'gemini-3.1-flash-lite-preview': { input: 0.25, output: 1.50 },
69
+ // Grok
70
+ 'grok-4.6': { input: 2.00, cachedInput: 0.50, output: 6.00, longContext: GROK46_LONG_CONTEXT_PRICING },
71
+ 'grok-4.5': { input: 2.00, output: 6.00 },
72
+ 'grok-4.3': { input: 1.25, output: 2.50 },
73
+ 'grok-4.20-multi-agent-0309': { input: 1.25, output: 2.50 },
74
+ 'grok-4.20-0309': { input: 1.25, output: 2.50 },
75
+ 'grok-4.20-0309-reasoning': { input: 1.25, output: 2.50 },
76
+ 'grok-4.20-0309-non-reasoning': { input: 1.25, output: 2.50 },
77
+ // Fireworks
78
+ 'accounts/fireworks/models/deepseek-v4-flash': { input: 0.14, output: 0.28 },
79
+ 'accounts/fireworks/models/deepseek-v4-pro': { input: 1.74, output: 3.48 },
80
+ 'accounts/fireworks/models/deepseek-v4-pro-0813': { input: 1.32, cachedInput: 0.044, output: 3.96 },
81
+ 'deepseek-ai/DeepSeek-V4-Flash': { input: 0.14, output: 0.28 },
82
+ 'deepseek-ai/DeepSeek-V4-Pro': { input: 2.10, output: 4.40 },
83
+ 'deepseek/deepseek-v4-flash': { input: 0.09, output: 0.18 },
84
+ 'accounts/fireworks/models/glm-4p7': { input: 0.55, output: 2.19 },
85
+ 'accounts/fireworks/models/glm-5p1': { input: 1.05, output: 3.50 },
86
+ 'zai-org/GLM-5.2': { input: 1.40, output: 4.40 },
87
+ 'accounts/fireworks/models/kimi-k2p5': { input: 0.50, output: 2.80 },
88
+ 'qwen/qwen3.5-397b-a17b': { input: 0.385, output: 2.45 },
89
+ 'accounts/fireworks/models/qwen3p6-plus': { input: 0.50, output: 3.00 },
90
+ 'Qwen/Qwen3.6-Plus': { input: 0.50, output: 3.00 },
91
+ 'accounts/fireworks/models/qwen3p7-plus': { input: 0.40, output: 1.60 },
92
+ 'qwen/qwen3.7-plus': { input: 0.32, output: 1.28 },
93
+ 'accounts/fireworks/models/qwen3p8-2p4t-a95b': { input: 2.00, cachedInput: 0.25, output: 6.00 },
94
+ 'qwen/qwen3.8-max': { input: 2.00, output: 6.00 },
95
+ // MiniMax
96
+ 'MiniMax-M2.5': { input: 0.30, output: 1.20 },
97
+ 'MiniMax-M2.7': { input: 0.30, output: 1.20 },
98
+ 'MiniMax-M3': { input: 0.30, output: 1.20 },
99
+ 'minimax/minimax-m2.7': { input: 0.30, output: 1.20 },
100
+ 'minimax/minimax-m3': { input: 0.30, output: 1.20 },
101
+ 'MiniMaxAI/MiniMax-M3': { input: 0.30, output: 1.20 },
102
+ // Perplexity
103
+ 'sonar': { input: 1.00, output: 1.00 },
104
+ 'sonar-pro': { input: 3.00, output: 15.00 },
105
+ // Hermes 4 (OpenRouter)
106
+ 'nousresearch/hermes-4-70b': { input: 0.13, output: 0.40 },
107
+ 'nousresearch/hermes-4-405b': { input: 1.00, output: 3.00 },
108
+ // Hermes 3 (Lambda/OpenRouter)
109
+ 'Hermes-3-Llama-3.1-405B-FP8': { input: 0.80, output: 0.80 },
110
+ 'nousresearch/hermes-3-llama-3.1-405b:free': { input: 0, output: 0 },
111
+ // Qwen3 (Together/Cerebras)
112
+ 'Qwen/Qwen3-235B-A22B-fp8-tput': { input: 0.20, output: 0.60 },
113
+ 'qwen-3-32b': { input: 0.20, output: 0.60 },
114
+ // Kimi K2.5 (Together/Fireworks/OpenRouter)
115
+ 'moonshotai/Kimi-K2.5': { input: 0.50, output: 2.80 },
116
+ 'moonshotai/kimi-k2.5': { input: 0.50, output: 2.80 },
117
+ // Kimi K3
118
+ 'kimi-k3': { input: 3.00, output: 15.00 },
119
+ 'moonshotai/kimi-k3': { input: 3.00, output: 15.00 },
120
+ // GLM 4.7 (OpenRouter/Cerebras)
121
+ 'z-ai/glm-4.7': { input: 0.55, output: 2.19 },
122
+ 'zai-glm-4.7': { input: 0.55, output: 2.19 },
123
+ };
124
+
125
+ function normalizeTokenUsage({ input = 0, output = 0, thinking = 0, total, cached = 0, cacheWrite = 0, cacheWrite5m = 0, cacheWrite1h = 0 } = {}) {
126
+ const tokenCount = value => Number.isFinite(value) ? Math.max(0, value) : 0;
127
+ const normalizedInput = tokenCount(input);
128
+ const normalizedOutput = tokenCount(output);
129
+ const normalizedThinking = tokenCount(thinking);
130
+ const normalizedCached = tokenCount(cached);
131
+ const normalizedCacheWrite5m = tokenCount(cacheWrite5m);
132
+ const normalizedCacheWrite1h = tokenCount(cacheWrite1h);
133
+ const normalizedCacheWrite = Math.max(
134
+ tokenCount(cacheWrite),
135
+ normalizedCacheWrite5m + normalizedCacheWrite1h
136
+ );
137
+ const normalizedTotal = Number.isFinite(total)
138
+ ? Math.max(0, total)
139
+ : normalizedInput + normalizedOutput + normalizedThinking;
140
+ const uncachedInput = Math.max(0, normalizedInput - normalizedCached - normalizedCacheWrite);
141
+ const cacheHitRate = normalizedInput > 0
142
+ ? Number((normalizedCached / normalizedInput).toFixed(4))
143
+ : 0;
144
+
145
+ return {
146
+ input: normalizedInput,
147
+ output: normalizedOutput,
148
+ thinking: normalizedThinking,
149
+ total: normalizedTotal,
150
+ cached: normalizedCached,
151
+ cacheWrite: normalizedCacheWrite,
152
+ cacheWrite5m: normalizedCacheWrite5m,
153
+ cacheWrite1h: normalizedCacheWrite1h,
154
+ uncachedInput,
155
+ cacheHitRate,
156
+ cacheSavings: 0,
157
+ cacheWritePremium: 0,
158
+ breakEvenHits: 0,
159
+ cost: 0,
160
+ costBreakdown: {
161
+ uncachedInput: 0,
162
+ cachedInput: 0,
163
+ cacheWrite: 0,
164
+ cacheWrite5m: 0,
165
+ cacheWrite1h: 0,
166
+ output: 0,
167
+ total: 0
168
+ }
169
+ };
170
+ }
171
+
172
+ function calculateCostBreakdown(modelKey, tokens) {
173
+ const pricing = MODEL_PRICING[modelKey];
174
+ if (!pricing) return normalizeTokenUsage().costBreakdown;
175
+
176
+ const normalized = normalizeTokenUsage(tokens);
177
+ const longContext = pricing.longContext;
178
+ const useLongContextRates = usesLongContextRates(pricing, normalized.input);
179
+ const inputMultiplier = useLongContextRates ? longContext.inputMultiplier : 1;
180
+ const outputMultiplier = useLongContextRates ? longContext.outputMultiplier : 1;
181
+ const {
182
+ input: inputPerMillion,
183
+ cachedInput: cachedInputPerMillion = inputPerMillion,
184
+ cacheWrite: cacheWritePerMillion = inputPerMillion,
185
+ cacheWrite1h: cacheWrite1hPerMillion = cacheWritePerMillion,
186
+ output: outputPerMillion
187
+ } = pricing;
188
+ const roundCost = value => Number(value.toFixed(12));
189
+ const genericCacheWrite = Math.max(
190
+ 0,
191
+ normalized.cacheWrite - normalized.cacheWrite5m - normalized.cacheWrite1h
192
+ );
193
+ const cacheWrite5mCost = roundCost(
194
+ normalized.cacheWrite5m * cacheWritePerMillion * inputMultiplier / 1_000_000
195
+ );
196
+ const cacheWrite1hCost = roundCost(
197
+ normalized.cacheWrite1h * cacheWrite1hPerMillion * inputMultiplier / 1_000_000
198
+ );
199
+ const genericCacheWriteCost = roundCost(
200
+ genericCacheWrite * cacheWritePerMillion * inputMultiplier / 1_000_000
201
+ );
202
+ const breakdown = {
203
+ uncachedInput: roundCost(normalized.uncachedInput * inputPerMillion * inputMultiplier / 1_000_000),
204
+ cachedInput: roundCost(normalized.cached * cachedInputPerMillion * inputMultiplier / 1_000_000),
205
+ cacheWrite: roundCost(genericCacheWriteCost + cacheWrite5mCost + cacheWrite1hCost),
206
+ cacheWrite5m: cacheWrite5mCost,
207
+ cacheWrite1h: cacheWrite1hCost,
208
+ output: roundCost(
209
+ (normalized.output + normalized.thinking) * outputPerMillion * outputMultiplier / 1_000_000
210
+ )
211
+ };
212
+ breakdown.total = roundCost(
213
+ breakdown.uncachedInput
214
+ + breakdown.cachedInput
215
+ + breakdown.cacheWrite
216
+ + breakdown.output
217
+ );
218
+ return breakdown;
219
+ }
220
+
221
+ function calculateCacheMetrics(modelKey, tokens) {
222
+ const pricing = MODEL_PRICING[modelKey];
223
+ const emptyMetrics = {
224
+ cacheSavings: 0,
225
+ cacheWritePremium: 0,
226
+ breakEvenHits: 0
227
+ };
228
+ if (!pricing) return emptyMetrics;
229
+
230
+ const normalized = normalizeTokenUsage(tokens);
231
+ const longContext = pricing.longContext;
232
+ const useLongContextRates = usesLongContextRates(pricing, normalized.input);
233
+ const inputMultiplier = useLongContextRates ? longContext.inputMultiplier : 1;
234
+ const cachedInputPerMillion = pricing.cachedInput ?? pricing.input;
235
+ const cacheWritePerMillion = pricing.cacheWrite ?? pricing.input;
236
+ const cacheWrite1hPerMillion = pricing.cacheWrite1h ?? cacheWritePerMillion;
237
+ const readSavingsPerMillion = Math.max(0, pricing.input - cachedInputPerMillion) * inputMultiplier;
238
+ const writePremiumPerMillion = Math.max(0, cacheWritePerMillion - pricing.input) * inputMultiplier;
239
+ const write1hPremiumPerMillion = Math.max(0, cacheWrite1hPerMillion - pricing.input) * inputMultiplier;
240
+ const roundCost = value => Number(value.toFixed(12));
241
+ const cacheSavings = roundCost(normalized.cached * readSavingsPerMillion / 1_000_000);
242
+ const genericCacheWrite = Math.max(
243
+ 0,
244
+ normalized.cacheWrite - normalized.cacheWrite5m - normalized.cacheWrite1h
245
+ );
246
+ const cacheWritePremium = roundCost(
247
+ (
248
+ (genericCacheWrite + normalized.cacheWrite5m) * writePremiumPerMillion
249
+ + normalized.cacheWrite1h * write1hPremiumPerMillion
250
+ ) / 1_000_000
251
+ );
252
+ const fullHitSavings = normalized.cacheWrite * readSavingsPerMillion / 1_000_000;
253
+
254
+ return {
255
+ cacheSavings,
256
+ cacheWritePremium,
257
+ breakEvenHits: fullHitSavings > 0
258
+ ? Number((cacheWritePremium / fullHitSavings).toFixed(4))
259
+ : 0
260
+ };
261
+ }
262
+
263
+ function calculateCost(modelKey, tokens) {
264
+ if (!hasModelPricing(modelKey)) return null;
265
+ return calculateCostBreakdown(modelKey, tokens).total;
266
+ }
267
+
268
+ function hasModelPricing(modelKey) {
269
+ return Object.prototype.hasOwnProperty.call(MODEL_PRICING, modelKey);
270
+ }
271
+
272
+ function extractCacheTokens(usage = {}) {
273
+ return usage.input_tokens_details?.cached_tokens
274
+ ?? usage.prompt_tokens_details?.cached_tokens
275
+ ?? usage.cache_read_input_tokens
276
+ ?? usage.cachedContentTokenCount
277
+ ?? usage.cached_content_token_count
278
+ ?? 0;
279
+ }
280
+
281
+ function extractCacheWriteTokens(usage = {}) {
282
+ return usage.input_tokens_details?.cache_write_tokens
283
+ ?? usage.prompt_tokens_details?.cache_write_tokens
284
+ ?? usage.cache_creation_input_tokens
285
+ ?? usage.cache_write_input_tokens
286
+ ?? usage.cacheWriteTokenCount
287
+ ?? usage.cache_write_token_count
288
+ ?? 0;
289
+ }
290
+
291
+ module.exports = {
292
+ normalizeTokenUsage,
293
+ calculateCostBreakdown,
294
+ calculateCacheMetrics,
295
+ calculateCost,
296
+ hasModelPricing,
297
+ extractCacheTokens,
298
+ extractCacheWriteTokens
299
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelmix",
3
- "version": "5.0.6",
3
+ "version": "5.1.2",
4
4
  "description": "🧬 Reliable interface with automatic fallback for AI LLMs.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -86,11 +86,11 @@ Chain shorthand methods to attach providers. First model is primary; others are
86
86
  const model = ModelMix.new()
87
87
  .sonnet46() // primary
88
88
  .gpt52() // fallback 1
89
- .gemini3flash() // fallback 2
89
+ .gemini37flash() // fallback 2
90
90
  .addText("Hello!")
91
91
  ```
92
92
 
93
- If `sonnet46` fails, it automatically tries `gpt52`, then `gemini3flash`.
93
+ If `sonnet46` fails, it automatically tries `gpt52`, then `gemini37flash`.
94
94
 
95
95
  The equivalent `chain()` form accepts public shortcut names directly in the
96
96
  same order. Append `@effort` for a per-model unified effort override (`-1` or
@@ -99,7 +99,7 @@ provider default when no chain effort is configured:
99
99
 
100
100
  ```javascript
101
101
  const model = ModelMix.new()
102
- .chain('sonnet46', 'gpt52@20', 'gemini3flash@-1')
102
+ .chain('sonnet46', 'gpt52@20', 'gemini37flash@-1')
103
103
  .addText('Hello!');
104
104
  ```
105
105
 
@@ -143,7 +143,7 @@ ModelMix.new({ config: { effort: 80 } })
143
143
  | DeepSeek V4 | off | `low`↑ | `high`↑ | `high`↑ | `max`↑ | — |
144
144
  | MiniMax M3 | off | adaptive | adaptive | adaptive | adaptive | adaptive |
145
145
 
146
- \* Gemini bands: 0–24 / 25–49 / 50–74 / 75–100. Gemini 3.7 Flash supports only `low` / `medium` / `high`, so the first two bands clamp to `low`; `-1` keeps its native `medium` default. DeepSeek `↑` = thinking on; `off` = thinking disabled. MiniMax `off`/`adaptive` = `thinking.disabled` / `thinking.type=adaptive`. Gemini 2.5 maps 0–100 to `thinkingBudget`. Anthropic: adaptive + `output_config.effort` on Claude 5 / Fable / Opus 4.6+ / Sonnet 4.6+; Sonnet 4.5 / Haiku 4.5 use `thinking.type=enabled` + `budget_tokens`. Grok 4.6 maps 0–39 / 40–59 / 60–79 / 80–100 to `low` / `medium` / `high` / `xhigh`; without effort it uses native `high`. `-1` = adaptive/dynamic when available, else no-op. Levels clamp per model. Former `*think()` methods are removed — use `.effort(n).<model>()`. Kimi: `kimiK25()` / `kimiK26()`. Grok 4.20: `.grok420()` non-reasoning; `.effort(20+|-1).grok420()` selects reasoning.
146
+ \* GPT-5.6 maps `100` to `max`; 80–99 remains `xhigh`. Gemini bands: 0–24 / 25–49 / 50–74 / 75–100. Gemini 3.7 Flash supports only `low` / `medium` / `high`, so the first two bands clamp to `low`; `-1` keeps its native `medium` default. DeepSeek `↑` = thinking on; `off` = thinking disabled. MiniMax `off`/`adaptive` = `thinking.disabled` / `thinking.type=adaptive`. Gemini 2.5 maps 0–100 to `thinkingBudget`. Anthropic: adaptive + `output_config.effort` on Claude 5 / Fable / Opus 4.6+ / Sonnet 4.6+; Sonnet 4.5 / Haiku 4.5 use `thinking.type=enabled` + `budget_tokens`. Grok 4.6 maps 0–39 / 40–59 / 60–79 / 80–100 to `low` / `medium` / `high` / `xhigh`; without effort it uses native `high`. `-1` = adaptive/dynamic when available, else no-op. Levels clamp per model. Former `*think()` methods are removed — use `.effort(n).<model>()`. Kimi: `kimiK25()` / `kimiK26()`. Grok 4.20: `.grok420()` non-reasoning; `.effort(20+|-1).grok420()` selects reasoning.
147
147
 
148
148
  ## Available Model Shorthands
149
149
 
@@ -151,7 +151,7 @@ ModelMix.new({ config: { effort: 80 } })
151
151
 
152
152
  Use `ModerationMix.new().openai()` with `.raw()` to classify text and images through OpenAI's Moderations endpoint. Read the results from `raw.moderation`. `ModerationMix` accepts moderation providers as ordered fallbacks, rejects generative providers, and does not generate text or support streaming.
153
153
 
154
- `gpt52()` `gpt52chat()` `gpt51()` `gpt5()` `gpt5mini()` `gpt5nano()` `gpt45()` `gpt41()` `gpt41mini()` `gpt41nano()` `o3()` `o4mini()`
154
+ `gpt52()` `gpt52chat()` `gpt51()` `gpt5()` `gpt5mini()` `gpt5nano()` `gpt45()` `o3()` `o4mini()`
155
155
 
156
156
  ### Anthropic
157
157
  `fable50()` `opus50()` `opus48()` `opus47()` `opus46()` `sonnet5()` `sonnet46()` `sonnet45()` `haiku45()`
@@ -159,7 +159,7 @@ Use `ModerationMix.new().openai()` with `.raw()` to classify text and images thr
159
159
  Use `.effort(n)` (or `config.effort`) to enable Anthropic thinking — e.g. `.effort(100).opus50()`. `fable5()` and `opus5()` remain available as compatibility aliases.
160
160
 
161
161
  ### Google
162
- `gemini3pro()` `gemini3flash()` `gemini37flash()` `gemini36flash()` `gemini35flash()` `gemini35flashLite()` `gemini31flashLite()` `gemini25pro()` `gemini25flash()`
162
+ `gemini31pro()` `gemini37flash()` `gemini36flash()` `gemini35flash()` `gemini35flashLite()` `gemini31flashLite()`
163
163
 
164
164
  ### Grok
165
165
  `grok46()` `grok45()` `grok43()` `grok420multiAgent()` `grok420()`
@@ -174,7 +174,7 @@ Use `.effort(n)` (or `config.effort`) to enable Anthropic thinking — e.g. `.ef
174
174
  `kimiK3()` — requires `MOONSHOT_API_KEY`; use `{ mix: { moonshot: false, openrouter: true } }` for OpenRouter.
175
175
 
176
176
  ### MiniMax
177
- `minimaxM25()` `minimaxM27()` `minimaxM3()`
177
+ `minimaxM27()` `minimaxM3()`
178
178
 
179
179
  ### Fireworks
180
180
  `qwen36plus()` `qwen37plus()` `qwen38max()` `deepseekV4Flash()` `deepseekV4Pro()` `kimiK26()`
@@ -460,7 +460,7 @@ Omit all weights for equal probabilities. Otherwise every option needs a positiv
460
460
  const pool = ModelMix.new({ config: { roundRobin: true } })
461
461
  .gpt5mini()
462
462
  .sonnet45()
463
- .gemini3flash();
463
+ .gemini37flash();
464
464
 
465
465
  const r1 = await pool.new().addText("Request 1").message();
466
466
  const r2 = await pool.new().addText("Request 2").message();
@@ -548,7 +548,7 @@ For full debug output, also set: `DEBUG=ModelMix* node script.js`
548
548
  ### Free-tier models
549
549
 
550
550
  ```javascript
551
- const model = ModelMix.new()
551
+ const model = ModelMix.new({ mix: { openrouter: false } })
552
552
  .gptOss()
553
553
  .kimiK25()
554
554
  .hermes3()
@@ -556,7 +556,7 @@ const model = ModelMix.new()
556
556
  console.log(await model.message());
557
557
  ```
558
558
 
559
- These use providers with free quotas (OpenRouter, Groq, Cerebras). If one runs out of quota, ModelMix falls back to the next.
559
+ These use providers with free quotas (Groq, Cerebras, and Together). OpenRouter is disabled because its GPT-OSS 120B route is no longer free. If one runs out of quota, ModelMix falls back to the next.
560
560
 
561
561
  ### Multi-provider routing
562
562
 
@@ -1,4 +1,5 @@
1
1
  const { expect } = require('chai');
2
+ const sinon = require('sinon');
2
3
  const {
3
4
  normalizeEffort,
4
5
  mapEffort,
@@ -69,6 +70,14 @@ describe('Unified effort scale', () => {
69
70
  expect(mapEffort('openai', 10)).to.deep.equal({ reasoning_effort: 'none' });
70
71
  expect(mapEffort('openai', 50)).to.deep.equal({ reasoning_effort: 'medium' });
71
72
  expect(mapEffort('openai', 90)).to.deep.equal({ reasoning_effort: 'xhigh' });
73
+ expect(mapEffort('openai', 100)).to.deep.equal({ reasoning_effort: 'xhigh' });
74
+ });
75
+
76
+ it('maps GPT-5.6 maximum unified effort to max', () => {
77
+ expect(mapEffort('openai', 99, 'gpt-5.6-luna')).to.deep.equal({ reasoning_effort: 'xhigh' });
78
+ for (const model of ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna']) {
79
+ expect(mapEffort('openai', 100, model)).to.deep.equal({ reasoning_effort: 'max' });
80
+ }
72
81
  });
73
82
 
74
83
  it('sets OpenAI adaptive only when supported (otherwise no-op)', () => {
@@ -351,6 +360,25 @@ describe('Unified effort scale', () => {
351
360
  });
352
361
  });
353
362
 
363
+ describe('debug logging', () => {
364
+ afterEach(() => {
365
+ sinon.restore();
366
+ });
367
+
368
+ it('includes the unified effort in the model header', async () => {
369
+ const provider = new MixOpenAIResponses();
370
+ sinon.stub(provider, 'create').resolves({ message: 'ok', toolCalls: [] });
371
+ const log = sinon.spy(console, 'log');
372
+ const model = ModelMix.new({ config: { debug: 1, effort: 60 } })
373
+ .attach('gpt-5.6-luna', provider)
374
+ .addText('Hello');
375
+
376
+ await model.message();
377
+
378
+ expect(log.calledWithMatch(/→ \[openairesponses:gpt-5\.6-luna@60\] #1/)).to.equal(true);
379
+ });
380
+ });
381
+
354
382
  describe('provider request wiring', () => {
355
383
  it('OpenAI Responses request uses mapped reasoning_effort', () => {
356
384
  const options = { model: 'gpt-5.2', messages: [] };
@@ -359,6 +387,14 @@ describe('Unified effort scale', () => {
359
387
  expect(request.reasoning).to.deep.equal({ effort: 'none' });
360
388
  });
361
389
 
390
+ it('GPT-5.6 Luna .effort(100) sends max reasoning effort', () => {
391
+ const model = ModelMix.new().effort(100).gpt56luna();
392
+ const options = { model: 'gpt-5.6-luna', messages: [] };
393
+ applyUnifiedEffort(options, model.config, 'openai', 'gpt-5.6-luna');
394
+ const request = MixOpenAIResponses.buildResponsesRequest(options, {});
395
+ expect(request.reasoning).to.deep.equal({ effort: 'max' });
396
+ });
397
+
362
398
  it('Anthropic config.effort maps through .effort().opus50()', () => {
363
399
  const model = ModelMix.new().effort(100).opus50();
364
400
  expect(model.config.effort).to.equal(100);
@@ -60,6 +60,18 @@ describe('Provider Fallback Chain Tests', () => {
60
60
  .to.throw('Invalid chain model at index 0: expected a model shortcut string.');
61
61
  });
62
62
 
63
+ it('should reject removed shortcuts from the public chain API', () => {
64
+ for (const shortcut of [
65
+ 'gpt41', 'gpt41mini', 'gpt41nano',
66
+ 'gemini25flash', 'gemini25pro', 'gemini3pro', 'gemini3flash',
67
+ 'minimaxM25'
68
+ ]) {
69
+ expect(model[shortcut]).to.equal(undefined);
70
+ expect(() => model.chain(shortcut))
71
+ .to.throw(`Unknown model shortcut "${shortcut}" in chain().`);
72
+ }
73
+ });
74
+
63
75
  it('should use primary provider when available', async () => {
64
76
  model.gpt5mini().sonnet46().addText('Hello');
65
77
 
@@ -195,7 +207,7 @@ describe('Provider Fallback Chain Tests', () => {
195
207
  });
196
208
 
197
209
  it('should cascade through multiple fallbacks', async () => {
198
- model.gpt5mini().sonnet46().gemini3flash().addText('Hello');
210
+ model.gpt5mini().sonnet46().gemini37flash().addText('Hello');
199
211
 
200
212
  // Mock failed OpenAI response
201
213
  nock('https://api.openai.com')
@@ -279,7 +291,7 @@ describe('Provider Fallback Chain Tests', () => {
279
291
  });
280
292
 
281
293
  it('should fallback from Anthropic to Google', async () => {
282
- model.sonnet46().gemini3flash().addText('Test message');
294
+ model.sonnet46().gemini37flash().addText('Test message');
283
295
 
284
296
  // Mock Anthropic failure
285
297
  nock('https://api.anthropic.com')
@@ -665,7 +677,7 @@ describe('Provider Fallback Chain Tests', () => {
665
677
  });
666
678
 
667
679
  it('should provide detailed error information when all fallbacks fail', async () => {
668
- model.gpt5mini().sonnet46().gemini3flash().addText('Test');
680
+ model.gpt5mini().sonnet46().gemini37flash().addText('Test');
669
681
 
670
682
  // Mock all providers failing with different errors
671
683
  nock('https://api.openai.com')
@@ -570,7 +570,7 @@ describe('Conversation History Tests', () => {
570
570
  const model = ModelMix.new({
571
571
  config: { debug: false, max_history: 10 }
572
572
  });
573
- model.gemini3flash();
573
+ model.gemini37flash();
574
574
 
575
575
  model.addText('Hello');
576
576
  nock('https://generativelanguage.googleapis.com')