modelmix 5.1.1 → 5.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.1.1",
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",
@@ -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,
@@ -359,6 +360,25 @@ describe('Unified effort scale', () => {
359
360
  });
360
361
  });
361
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
+
362
382
  describe('provider request wiring', () => {
363
383
  it('OpenAI Responses request uses mapped reasoning_effort', () => {
364
384
  const options = { model: 'gpt-5.2', messages: [] };
@@ -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
+ });
@@ -576,6 +576,26 @@ describe('Token Usage Tracking', () => {
576
576
  }
577
577
  });
578
578
 
579
+ it('should register GPT-OSS 120B through the current OpenRouter model ID', function () {
580
+ const originalOpenRouterApiKey = process.env.OPENROUTER_API_KEY;
581
+ process.env.OPENROUTER_API_KEY = 'test-openrouter-key';
582
+
583
+ try {
584
+ const model = ModelMix.new().gptOss({
585
+ mix: { cerebras: false, groq: true, openrouter: true, together: false }
586
+ });
587
+
588
+ expect(model.models.map(({ key }) => key)).to.deep.equal([
589
+ 'openai/gpt-oss-120b',
590
+ 'openai/gpt-oss-120b'
591
+ ]);
592
+ expect(model.models[1].provider).to.be.instanceOf(MixOpenRouter);
593
+ } finally {
594
+ if (originalOpenRouterApiKey === undefined) delete process.env.OPENROUTER_API_KEY;
595
+ else process.env.OPENROUTER_API_KEY = originalOpenRouterApiKey;
596
+ }
597
+ });
598
+
579
599
  it('should use api-key header for MiMo provider', function () {
580
600
  const originalMimoApiKey = process.env.MIMO_API_KEY;
581
601
  process.env.MIMO_API_KEY = 'test-mimo-key';