modelmix 5.1.1 → 5.1.4

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,301 @@
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
+ 'z-ai/glm-5.3': { input: 1.40, cachedInput: 0.26, output: 4.40 },
88
+ 'accounts/fireworks/models/kimi-k2p5': { input: 0.50, output: 2.80 },
89
+ 'qwen/qwen3.5-397b-a17b': { input: 0.385, output: 2.45 },
90
+ 'accounts/fireworks/models/qwen3p6-plus': { input: 0.50, output: 3.00 },
91
+ 'Qwen/Qwen3.6-Plus': { input: 0.50, output: 3.00 },
92
+ 'accounts/fireworks/models/qwen3p7-plus': { input: 0.40, output: 1.60 },
93
+ 'qwen/qwen3.7-plus': { input: 0.32, output: 1.28 },
94
+ 'accounts/fireworks/models/qwen3p8-2p4t-a95b': { input: 2.00, cachedInput: 0.25, output: 6.00 },
95
+ 'qwen/qwen3.8-max': { input: 2.00, output: 6.00 },
96
+ 'qwen/qwen3.8-27b': { input: 0.45, cachedInput: 0.05, output: 3.20 },
97
+ // MiniMax
98
+ 'MiniMax-M2.5': { input: 0.30, output: 1.20 },
99
+ 'MiniMax-M2.7': { input: 0.30, output: 1.20 },
100
+ 'MiniMax-M3': { input: 0.30, output: 1.20 },
101
+ 'minimax/minimax-m2.7': { input: 0.30, output: 1.20 },
102
+ 'minimax/minimax-m3': { input: 0.30, output: 1.20 },
103
+ 'MiniMaxAI/MiniMax-M3': { input: 0.30, output: 1.20 },
104
+ // Perplexity
105
+ 'sonar': { input: 1.00, output: 1.00 },
106
+ 'sonar-pro': { input: 3.00, output: 15.00 },
107
+ // Hermes 4 (OpenRouter)
108
+ 'nousresearch/hermes-4-70b': { input: 0.13, output: 0.40 },
109
+ 'nousresearch/hermes-4-405b': { input: 1.00, output: 3.00 },
110
+ // Hermes 3 (Lambda/OpenRouter)
111
+ 'Hermes-3-Llama-3.1-405B-FP8': { input: 0.80, output: 0.80 },
112
+ 'nousresearch/hermes-3-llama-3.1-405b:free': { input: 0, output: 0 },
113
+ // Qwen3 (Together/Cerebras)
114
+ 'Qwen/Qwen3-235B-A22B-fp8-tput': { input: 0.20, output: 0.60 },
115
+ 'qwen-3-32b': { input: 0.20, output: 0.60 },
116
+ // Kimi K2.5 (Together/Fireworks/OpenRouter)
117
+ 'moonshotai/Kimi-K2.5': { input: 0.50, output: 2.80 },
118
+ 'moonshotai/kimi-k2.5': { input: 0.50, output: 2.80 },
119
+ // Kimi K3
120
+ 'kimi-k3': { input: 3.00, output: 15.00 },
121
+ 'moonshotai/kimi-k3': { input: 3.00, output: 15.00 },
122
+ // GLM 4.7 (OpenRouter/Cerebras)
123
+ 'z-ai/glm-4.7': { input: 0.55, output: 2.19 },
124
+ 'zai-glm-4.7': { input: 0.55, output: 2.19 },
125
+ };
126
+
127
+ function normalizeTokenUsage({ input = 0, output = 0, thinking = 0, total, cached = 0, cacheWrite = 0, cacheWrite5m = 0, cacheWrite1h = 0 } = {}) {
128
+ const tokenCount = value => Number.isFinite(value) ? Math.max(0, value) : 0;
129
+ const normalizedInput = tokenCount(input);
130
+ const normalizedOutput = tokenCount(output);
131
+ const normalizedThinking = tokenCount(thinking);
132
+ const normalizedCached = tokenCount(cached);
133
+ const normalizedCacheWrite5m = tokenCount(cacheWrite5m);
134
+ const normalizedCacheWrite1h = tokenCount(cacheWrite1h);
135
+ const normalizedCacheWrite = Math.max(
136
+ tokenCount(cacheWrite),
137
+ normalizedCacheWrite5m + normalizedCacheWrite1h
138
+ );
139
+ const normalizedTotal = Number.isFinite(total)
140
+ ? Math.max(0, total)
141
+ : normalizedInput + normalizedOutput + normalizedThinking;
142
+ const uncachedInput = Math.max(0, normalizedInput - normalizedCached - normalizedCacheWrite);
143
+ const cacheHitRate = normalizedInput > 0
144
+ ? Number((normalizedCached / normalizedInput).toFixed(4))
145
+ : 0;
146
+
147
+ return {
148
+ input: normalizedInput,
149
+ output: normalizedOutput,
150
+ thinking: normalizedThinking,
151
+ total: normalizedTotal,
152
+ cached: normalizedCached,
153
+ cacheWrite: normalizedCacheWrite,
154
+ cacheWrite5m: normalizedCacheWrite5m,
155
+ cacheWrite1h: normalizedCacheWrite1h,
156
+ uncachedInput,
157
+ cacheHitRate,
158
+ cacheSavings: 0,
159
+ cacheWritePremium: 0,
160
+ breakEvenHits: 0,
161
+ cost: 0,
162
+ costBreakdown: {
163
+ uncachedInput: 0,
164
+ cachedInput: 0,
165
+ cacheWrite: 0,
166
+ cacheWrite5m: 0,
167
+ cacheWrite1h: 0,
168
+ output: 0,
169
+ total: 0
170
+ }
171
+ };
172
+ }
173
+
174
+ function calculateCostBreakdown(modelKey, tokens) {
175
+ const pricing = MODEL_PRICING[modelKey];
176
+ if (!pricing) return normalizeTokenUsage().costBreakdown;
177
+
178
+ const normalized = normalizeTokenUsage(tokens);
179
+ const longContext = pricing.longContext;
180
+ const useLongContextRates = usesLongContextRates(pricing, normalized.input);
181
+ const inputMultiplier = useLongContextRates ? longContext.inputMultiplier : 1;
182
+ const outputMultiplier = useLongContextRates ? longContext.outputMultiplier : 1;
183
+ const {
184
+ input: inputPerMillion,
185
+ cachedInput: cachedInputPerMillion = inputPerMillion,
186
+ cacheWrite: cacheWritePerMillion = inputPerMillion,
187
+ cacheWrite1h: cacheWrite1hPerMillion = cacheWritePerMillion,
188
+ output: outputPerMillion
189
+ } = pricing;
190
+ const roundCost = value => Number(value.toFixed(12));
191
+ const genericCacheWrite = Math.max(
192
+ 0,
193
+ normalized.cacheWrite - normalized.cacheWrite5m - normalized.cacheWrite1h
194
+ );
195
+ const cacheWrite5mCost = roundCost(
196
+ normalized.cacheWrite5m * cacheWritePerMillion * inputMultiplier / 1_000_000
197
+ );
198
+ const cacheWrite1hCost = roundCost(
199
+ normalized.cacheWrite1h * cacheWrite1hPerMillion * inputMultiplier / 1_000_000
200
+ );
201
+ const genericCacheWriteCost = roundCost(
202
+ genericCacheWrite * cacheWritePerMillion * inputMultiplier / 1_000_000
203
+ );
204
+ const breakdown = {
205
+ uncachedInput: roundCost(normalized.uncachedInput * inputPerMillion * inputMultiplier / 1_000_000),
206
+ cachedInput: roundCost(normalized.cached * cachedInputPerMillion * inputMultiplier / 1_000_000),
207
+ cacheWrite: roundCost(genericCacheWriteCost + cacheWrite5mCost + cacheWrite1hCost),
208
+ cacheWrite5m: cacheWrite5mCost,
209
+ cacheWrite1h: cacheWrite1hCost,
210
+ output: roundCost(
211
+ (normalized.output + normalized.thinking) * outputPerMillion * outputMultiplier / 1_000_000
212
+ )
213
+ };
214
+ breakdown.total = roundCost(
215
+ breakdown.uncachedInput
216
+ + breakdown.cachedInput
217
+ + breakdown.cacheWrite
218
+ + breakdown.output
219
+ );
220
+ return breakdown;
221
+ }
222
+
223
+ function calculateCacheMetrics(modelKey, tokens) {
224
+ const pricing = MODEL_PRICING[modelKey];
225
+ const emptyMetrics = {
226
+ cacheSavings: 0,
227
+ cacheWritePremium: 0,
228
+ breakEvenHits: 0
229
+ };
230
+ if (!pricing) return emptyMetrics;
231
+
232
+ const normalized = normalizeTokenUsage(tokens);
233
+ const longContext = pricing.longContext;
234
+ const useLongContextRates = usesLongContextRates(pricing, normalized.input);
235
+ const inputMultiplier = useLongContextRates ? longContext.inputMultiplier : 1;
236
+ const cachedInputPerMillion = pricing.cachedInput ?? pricing.input;
237
+ const cacheWritePerMillion = pricing.cacheWrite ?? pricing.input;
238
+ const cacheWrite1hPerMillion = pricing.cacheWrite1h ?? cacheWritePerMillion;
239
+ const readSavingsPerMillion = Math.max(0, pricing.input - cachedInputPerMillion) * inputMultiplier;
240
+ const writePremiumPerMillion = Math.max(0, cacheWritePerMillion - pricing.input) * inputMultiplier;
241
+ const write1hPremiumPerMillion = Math.max(0, cacheWrite1hPerMillion - pricing.input) * inputMultiplier;
242
+ const roundCost = value => Number(value.toFixed(12));
243
+ const cacheSavings = roundCost(normalized.cached * readSavingsPerMillion / 1_000_000);
244
+ const genericCacheWrite = Math.max(
245
+ 0,
246
+ normalized.cacheWrite - normalized.cacheWrite5m - normalized.cacheWrite1h
247
+ );
248
+ const cacheWritePremium = roundCost(
249
+ (
250
+ (genericCacheWrite + normalized.cacheWrite5m) * writePremiumPerMillion
251
+ + normalized.cacheWrite1h * write1hPremiumPerMillion
252
+ ) / 1_000_000
253
+ );
254
+ const fullHitSavings = normalized.cacheWrite * readSavingsPerMillion / 1_000_000;
255
+
256
+ return {
257
+ cacheSavings,
258
+ cacheWritePremium,
259
+ breakEvenHits: fullHitSavings > 0
260
+ ? Number((cacheWritePremium / fullHitSavings).toFixed(4))
261
+ : 0
262
+ };
263
+ }
264
+
265
+ function calculateCost(modelKey, tokens) {
266
+ if (!hasModelPricing(modelKey)) return null;
267
+ return calculateCostBreakdown(modelKey, tokens).total;
268
+ }
269
+
270
+ function hasModelPricing(modelKey) {
271
+ return Object.prototype.hasOwnProperty.call(MODEL_PRICING, modelKey);
272
+ }
273
+
274
+ function extractCacheTokens(usage = {}) {
275
+ return usage.input_tokens_details?.cached_tokens
276
+ ?? usage.prompt_tokens_details?.cached_tokens
277
+ ?? usage.cache_read_input_tokens
278
+ ?? usage.cachedContentTokenCount
279
+ ?? usage.cached_content_token_count
280
+ ?? 0;
281
+ }
282
+
283
+ function extractCacheWriteTokens(usage = {}) {
284
+ return usage.input_tokens_details?.cache_write_tokens
285
+ ?? usage.prompt_tokens_details?.cache_write_tokens
286
+ ?? usage.cache_creation_input_tokens
287
+ ?? usage.cache_write_input_tokens
288
+ ?? usage.cacheWriteTokenCount
289
+ ?? usage.cache_write_token_count
290
+ ?? 0;
291
+ }
292
+
293
+ module.exports = {
294
+ normalizeTokenUsage,
295
+ calculateCostBreakdown,
296
+ calculateCacheMetrics,
297
+ calculateCost,
298
+ hasModelPricing,
299
+ extractCacheTokens,
300
+ extractCacheWriteTokens
301
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelmix",
3
- "version": "5.1.1",
3
+ "version": "5.1.4",
4
4
  "description": "🧬 Reliable interface with automatic fallback for AI LLMs.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -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
- \* 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.
146
+ \* GPT-5.6 maps `100` to `max`; 80–99 remains `xhigh`. Qwen 3.8 27B maps 0–39 / 40–79 / 80–100 to `low` / `medium` / `xhigh`. GLM 5.3 requires reasoning and maps those bands to `low` / `high` / `max`. 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
 
@@ -183,7 +183,7 @@ Use `.effort(n)` (or `config.effort`) to enable Anthropic thinking — e.g. `.ef
183
183
  `GLM46()`
184
184
 
185
185
  ### OpenRouter
186
- `qwen35397b()` `hermes470b()` `hermes4405b()` `qwen38max()` `GLM45()`
186
+ `qwen35397b()` `qwen3827b()` `hermes470b()` `hermes4405b()` `qwen38max()` `GLM45()` `GLM53()`
187
187
 
188
188
  ### Multi-provider (auto-fallback across free/paid tiers)
189
189
  `hermes3()` `kimiK25()`
@@ -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,
@@ -95,6 +96,23 @@ describe('Unified effort scale', () => {
95
96
  expect(mapEffort('openai', 100, key)).to.deep.equal({ reasoning_effort: 'high' });
96
97
  });
97
98
 
99
+ it('maps Qwen 3.8 27B to its supported reasoning levels', () => {
100
+ const key = 'qwen/qwen3.8-27b';
101
+ expect(mapEffort('openai', 0, key)).to.deep.equal({ reasoning_effort: 'low' });
102
+ expect(mapEffort('openai', 50, key)).to.deep.equal({ reasoning_effort: 'medium' });
103
+ expect(mapEffort('openai', 100, key)).to.deep.equal({ reasoning_effort: 'xhigh' });
104
+ expect(mapEffort('openai', -1, key)).to.equal(null);
105
+ });
106
+
107
+ it('maps GLM 5.3 to mandatory low, high, and max reasoning', () => {
108
+ const key = 'z-ai/glm-5.3';
109
+ expect(mapEffort('openai', 39, key)).to.deep.equal({ reasoning_effort: 'low' });
110
+ expect(mapEffort('openai', 40, key)).to.deep.equal({ reasoning_effort: 'high' });
111
+ expect(mapEffort('openai', 79, key)).to.deep.equal({ reasoning_effort: 'high' });
112
+ expect(mapEffort('openai', 80, key)).to.deep.equal({ reasoning_effort: 'max' });
113
+ expect(mapEffort('openai', -1, key)).to.equal(null);
114
+ });
115
+
98
116
  it('maps Anthropic adaptive models to thinking + output_config.effort', () => {
99
117
  expect(mapEffort('anthropic', 10, 'claude-opus-5')).to.deep.equal({
100
118
  thinking: { type: 'adaptive', display: 'summarized' },
@@ -359,6 +377,25 @@ describe('Unified effort scale', () => {
359
377
  });
360
378
  });
361
379
 
380
+ describe('debug logging', () => {
381
+ afterEach(() => {
382
+ sinon.restore();
383
+ });
384
+
385
+ it('includes the unified effort in the model header', async () => {
386
+ const provider = new MixOpenAIResponses();
387
+ sinon.stub(provider, 'create').resolves({ message: 'ok', toolCalls: [] });
388
+ const log = sinon.spy(console, 'log');
389
+ const model = ModelMix.new({ config: { debug: 1, effort: 60 } })
390
+ .attach('gpt-5.6-luna', provider)
391
+ .addText('Hello');
392
+
393
+ await model.message();
394
+
395
+ expect(log.calledWithMatch(/→ \[openairesponses:gpt-5\.6-luna@60\] #1/)).to.equal(true);
396
+ });
397
+ });
398
+
362
399
  describe('provider request wiring', () => {
363
400
  it('OpenAI Responses request uses mapped reasoning_effort', () => {
364
401
  const options = { model: 'gpt-5.2', messages: [] };
package/test/glm.test.js CHANGED
@@ -1,5 +1,5 @@
1
1
  const { expect } = require('chai');
2
- const { ModelMix } = require('../index.js');
2
+ const { ModelMix, MixOpenRouter } = require('../index.js');
3
3
 
4
4
  describe('GLM Model Registration Tests', () => {
5
5
  it('should register Together GLM 5.2 by default', () => {
@@ -9,4 +9,24 @@ describe('GLM Model Registration Tests', () => {
9
9
  expect(model.models).to.have.length(1);
10
10
  expect(model.models[0].key).to.equal('zai-org/GLM-5.2');
11
11
  });
12
+
13
+ it('should register GLM 5.3 through OpenRouter', () => {
14
+ const model = ModelMix.new().GLM53();
15
+
16
+ expect(model.models).to.have.length(1);
17
+ expect(model.models[0].key).to.equal('z-ai/glm-5.3');
18
+ expect(model.models[0].provider).to.be.instanceOf(MixOpenRouter);
19
+ expect(ModelMix.calculateCost('z-ai/glm-5.3', {
20
+ input: 1_000_000,
21
+ cached: 500_000,
22
+ output: 1_000_000
23
+ })).to.equal(5.23);
24
+ });
25
+
26
+ it('should support GLM 5.3 in chain()', () => {
27
+ const model = ModelMix.new().chain('GLM53');
28
+
29
+ expect(model.models).to.have.length(1);
30
+ expect(model.models[0].key).to.equal('z-ai/glm-5.3');
31
+ });
12
32
  });
@@ -0,0 +1,12 @@
1
+ const { expect } = require('chai');
2
+
3
+ const { ModelMix } = require('../index.js');
4
+ const { listChainModelShortcuts } = require('../lib/model-chain');
5
+
6
+ describe('model chain catalog', () => {
7
+ it('contains only implemented ModelMix shortcuts', () => {
8
+ for (const shortcut of listChainModelShortcuts()) {
9
+ expect(ModelMix.prototype[shortcut], shortcut).to.be.a('function');
10
+ }
11
+ });
12
+ });
@@ -0,0 +1,54 @@
1
+ const { expect } = require('chai');
2
+
3
+ const api = require('../index.js');
4
+
5
+ describe('public module boundary', () => {
6
+ it('preserves the CommonJS export surface', () => {
7
+ expect(Object.keys(api).sort()).to.deep.equal([
8
+ 'MixAnthropic',
9
+ 'MixCerebras',
10
+ 'MixCustom',
11
+ 'MixFireworks',
12
+ 'MixGoogle',
13
+ 'MixGrok',
14
+ 'MixGroq',
15
+ 'MixKimi',
16
+ 'MixLMStudio',
17
+ 'MixMiMo',
18
+ 'MixMiniMax',
19
+ 'MixModeration',
20
+ 'MixNVIDIA',
21
+ 'MixOllama',
22
+ 'MixOpenAI',
23
+ 'MixOpenAIModeration',
24
+ 'MixOpenAIResponses',
25
+ 'MixOpenAIWebSocket',
26
+ 'MixOpenRouter',
27
+ 'MixPerplexity',
28
+ 'MixTogether',
29
+ 'ModelMix',
30
+ 'ModerationMix',
31
+ 'applyUnifiedEffort',
32
+ 'normalizeEffort',
33
+ 'resolveProviderFamily'
34
+ ]);
35
+ });
36
+
37
+ it('preserves provider inheritance and class identity', () => {
38
+ expect(Object.getPrototypeOf(api.MixOpenAIResponses.prototype)).to.equal(api.MixOpenAI.prototype);
39
+ expect(Object.getPrototypeOf(api.MixOpenAIModeration.prototype)).to.equal(api.MixModeration.prototype);
40
+ expect(Object.getPrototypeOf(api.ModerationMix.prototype)).to.equal(api.ModelMix.prototype);
41
+ expect(require('../index.js').MixCustom).to.equal(api.MixCustom);
42
+ });
43
+
44
+ it('keeps root exports usable by model shortcuts', () => {
45
+ const model = api.ModelMix.new().qwen35397b({ config: { apiKey: 'test-key' } });
46
+
47
+ expect(model.models).to.have.length(1);
48
+ expect(model.models[0].provider.constructor).to.equal(api.MixOpenRouter);
49
+ });
50
+
51
+ it('keeps the mutable pricing catalog private', () => {
52
+ expect(require('../lib/token-usage')).to.not.have.property('MODEL_PRICING');
53
+ });
54
+ });