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.
- package/README.md +2 -2
- package/demo/free.js +2 -3
- package/effort.js +0 -1
- package/index.js +513 -2847
- package/lib/content-cache.js +31 -0
- package/lib/model-chain.js +51 -0
- package/lib/object-utils.js +7 -0
- package/lib/provider-debug.js +23 -0
- package/lib/providers/anthropic.js +337 -0
- package/lib/providers/base.js +409 -0
- package/lib/providers/google.js +293 -0
- package/lib/providers/openai-compatible.js +338 -0
- package/lib/providers/openai.js +622 -0
- package/lib/providers.js +26 -0
- package/lib/template-engine.js +168 -0
- package/lib/token-usage.js +299 -0
- package/package.json +1 -1
- package/skills/modelmix/SKILL.md +2 -2
- package/test/effort.test.js +20 -0
- package/test/model-chain.test.js +12 -0
- package/test/public-api.test.js +54 -0
- package/test/tokens.test.js +20 -0
|
@@ -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
package/skills/modelmix/SKILL.md
CHANGED
|
@@ -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 (
|
|
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
|
|
package/test/effort.test.js
CHANGED
|
@@ -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
|
+
});
|
package/test/tokens.test.js
CHANGED
|
@@ -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';
|