modelmix 5.1.20 → 5.2.1
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/.gitignore +138 -0
- package/README.md +69 -2
- package/demo/benchmark.js +83 -0
- package/demo/package.json +2 -0
- package/demo/prompts/story.txt +35 -0
- package/demo/prompts/template-engine.txt +41 -0
- package/demo/short.js +2 -0
- package/effort.js +3 -1
- package/index.d.ts +9 -5
- package/index.js +75 -12
- package/lib/model-chain.js +1 -1
- package/lib/parse-json-response.js +14 -0
- package/lib/providers/anthropic.js +3 -1
- package/lib/providers/base.js +65 -17
- package/lib/providers/google.js +13 -2
- package/lib/providers/openai-compatible.js +36 -0
- package/lib/providers/openai.js +52 -8
- package/lib/token-usage.js +5 -0
- package/package.json +5 -2
- package/plugins/benchmark/index.d.ts +112 -0
- package/plugins/benchmark/index.js +575 -0
- package/plugins/benchmark/test/benchmark.test.js +518 -0
- package/plugins/skills/index.d.ts +9 -0
- package/plugins/skills/index.js +107 -0
- package/plugins/skills/test/skills.test.js +182 -0
- package/pnpm-workspace.yaml +2 -2
- package/skills/modelmix/SKILL.md +32 -2
- package/test/deepseek.test.js +273 -1
- package/test/fallback.test.js +57 -0
- package/test/google.test.js +55 -0
- package/test/history.test.js +7 -4
- package/test/json.test.js +29 -1
- package/test/plugins.test.js +150 -1
- package/test/public-api.test.js +2 -0
- package/test/tokens.test.js +75 -3
- package/RLM_PLUGIN_SPEC.md +0 -465
- package/demo/package-lock.json +0 -516
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const { expect } = require('chai');
|
|
2
|
+
const sinon = require('sinon');
|
|
3
|
+
const { MixGoogle, ModelMix } = require('../index');
|
|
4
|
+
|
|
5
|
+
describe('Google message and JSON contracts', () => {
|
|
6
|
+
afterEach(() => sinon.restore());
|
|
7
|
+
|
|
8
|
+
it('converts string user and assistant messages without changing their inputs', () => {
|
|
9
|
+
const messages = [
|
|
10
|
+
{ role: 'user', content: 'Request' },
|
|
11
|
+
{ role: 'assistant', content: 'Answer' },
|
|
12
|
+
{ role: 'user', content: [{ type: 'text', text: 'Follow-up' }] }
|
|
13
|
+
];
|
|
14
|
+
expect(MixGoogle.convertMessages(messages)).to.deep.equal([
|
|
15
|
+
{ role: 'user', parts: [{ text: 'Request' }] },
|
|
16
|
+
{ role: 'model', parts: [{ text: 'Answer' }] },
|
|
17
|
+
{ role: 'user', parts: [{ text: 'Follow-up' }] }
|
|
18
|
+
]);
|
|
19
|
+
expect(messages[0]).to.deep.equal({ role: 'user', content: 'Request' });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('sends child text messages and JSON mode through the real Google adapter', async () => {
|
|
23
|
+
const bodies = [];
|
|
24
|
+
sinon.stub(global, 'fetch').callsFake(async (_url, input) => {
|
|
25
|
+
bodies.push(JSON.parse(input.body));
|
|
26
|
+
return new Response(JSON.stringify({
|
|
27
|
+
candidates: [{ content: { parts: [{ text: '{"ok":true}' }] } }]
|
|
28
|
+
}), { status: 200 });
|
|
29
|
+
});
|
|
30
|
+
const worker = ModelMix.new({ config: { apiKey: 'test-key' } }).gemini38flash();
|
|
31
|
+
const result = await ModelMix.new().use({
|
|
32
|
+
name: 'child-json',
|
|
33
|
+
execute: context => context.invoke({
|
|
34
|
+
model: worker,
|
|
35
|
+
system: 'Return JSON.',
|
|
36
|
+
messages: [{ role: 'user', content: 'Evaluate this response.' }],
|
|
37
|
+
options: { response_format: { type: 'json_object' } },
|
|
38
|
+
plugins: 'none'
|
|
39
|
+
})
|
|
40
|
+
}).addText('Task').json();
|
|
41
|
+
expect(result).to.deep.equal({ ok: true });
|
|
42
|
+
expect(bodies[0].contents).to.deep.equal([
|
|
43
|
+
{ role: 'user', parts: [{ text: 'Evaluate this response.' }] }
|
|
44
|
+
]);
|
|
45
|
+
expect(bodies[0].generationConfig.responseMimeType).to.equal('application/json');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('combines answer text parts without including thought parts', () => {
|
|
49
|
+
expect(MixGoogle.extractMessage({ candidates: [{ content: { parts: [
|
|
50
|
+
{ thought: true, text: 'Internal analysis' },
|
|
51
|
+
{ text: '{"ok":' },
|
|
52
|
+
{ text: 'true}' }
|
|
53
|
+
] } }] })).to.equal('{"ok":true}');
|
|
54
|
+
});
|
|
55
|
+
});
|
package/test/history.test.js
CHANGED
|
@@ -629,7 +629,7 @@ describe('Conversation History Tests', () => {
|
|
|
629
629
|
expect(model.messages).to.have.length(2);
|
|
630
630
|
});
|
|
631
631
|
|
|
632
|
-
it('should
|
|
632
|
+
it('should reject an empty assistant response without adding it to history', async () => {
|
|
633
633
|
const model = ModelMix.new({
|
|
634
634
|
config: { debug: false, max_history: 10 }
|
|
635
635
|
});
|
|
@@ -643,9 +643,12 @@ describe('Conversation History Tests', () => {
|
|
|
643
643
|
}]
|
|
644
644
|
});
|
|
645
645
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
expect(
|
|
646
|
+
let failure;
|
|
647
|
+
try { await model.message(); } catch (error) { failure = error; }
|
|
648
|
+
expect(failure).to.include({ statusCode: 502 });
|
|
649
|
+
expect(failure.message).to.include('no text or tool calls');
|
|
650
|
+
expect(model.messages).to.have.length(1);
|
|
651
|
+
expect(model.messages[0].role).to.equal('user');
|
|
649
652
|
});
|
|
650
653
|
|
|
651
654
|
it('should handle multiple addText before first message()', async () => {
|
package/test/json.test.js
CHANGED
|
@@ -17,6 +17,34 @@ describe('JSON Schema and Structured Output Tests', () => {
|
|
|
17
17
|
sinon.restore();
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
+
describe('JSON response formatting', () => {
|
|
21
|
+
for (const suffix of ['', '```', '\n```\n']) {
|
|
22
|
+
it(`preserves embedded Markdown with trailing delimiter ${JSON.stringify(suffix)}`, async () => {
|
|
23
|
+
const value = { text: 'Use ```text\ncontent\n``` here.' };
|
|
24
|
+
const message = JSON.stringify(value) + suffix;
|
|
25
|
+
const mix = ModelMix.new();
|
|
26
|
+
const result = { message };
|
|
27
|
+
sinon.stub(mix, 'execute').resolves(result);
|
|
28
|
+
expect(await mix.json()).to.deep.equal(value);
|
|
29
|
+
expect(result.message).to.equal(message);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (const message of ['{"ok":true} explanation```', '{"ok":true}{"other":true}```', '{"ok"":true}```']) {
|
|
34
|
+
it(`rejects invalid content even with a closing delimiter: ${message}`, async () => {
|
|
35
|
+
const mix = ModelMix.new();
|
|
36
|
+
sinon.stub(mix, 'execute').resolves({ message });
|
|
37
|
+
let failure;
|
|
38
|
+
try {
|
|
39
|
+
await mix.json();
|
|
40
|
+
} catch (error) {
|
|
41
|
+
failure = error;
|
|
42
|
+
}
|
|
43
|
+
expect(failure).to.be.instanceOf(SyntaxError);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
20
48
|
describe('JSON Schema Generation', () => {
|
|
21
49
|
it('should generate schema for simple object', () => {
|
|
22
50
|
const example = {
|
|
@@ -490,4 +518,4 @@ describe('JSON Schema and Structured Output Tests', () => {
|
|
|
490
518
|
expect(result[2]).to.have.property('name', 'Spain');
|
|
491
519
|
});
|
|
492
520
|
});
|
|
493
|
-
});
|
|
521
|
+
});
|
package/test/plugins.test.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
const { expect } = require('chai');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const
|
|
3
|
+
const nock = require('nock');
|
|
4
|
+
const { MixCustom, MixOpenAIResponses, MixAnthropic, MixGoogle, ModelMix } = require('../index.js');
|
|
5
|
+
const { skills } = require('../plugins/skills');
|
|
4
6
|
|
|
5
7
|
function createProvider(handler = async () => ({ message: 'provider', toolCalls: [] })) {
|
|
6
8
|
const provider = new MixCustom();
|
|
@@ -9,6 +11,153 @@ function createProvider(handler = async () => ({ message: 'provider', toolCalls:
|
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
describe('ModelMix plugins', () => {
|
|
14
|
+
it('runs a native Responses skill loop including reasoning and parallel tool outputs', async () => {
|
|
15
|
+
const requests = [];
|
|
16
|
+
const output = [
|
|
17
|
+
{ type: 'reasoning', id: 'rs_skill', summary: [], encrypted_content: 'opaque-reasoning' },
|
|
18
|
+
{ type: 'message', id: 'msg_skill', role: 'assistant', content: [{ type: 'output_text', text: 'Reading the skill.' }] },
|
|
19
|
+
{ type: 'function_call', id: 'fc_skill', call_id: 'call_skill', name: 'read_skill', arguments: JSON.stringify({ name: 'modelmix' }) },
|
|
20
|
+
{ type: 'function_call', id: 'fc_local', call_id: 'call_local', name: 'local_tool', arguments: '{"value":7}' }
|
|
21
|
+
];
|
|
22
|
+
const scope = nock('https://api.openai.com')
|
|
23
|
+
.post('/v1/responses', body => { requests.push(body); return true; })
|
|
24
|
+
.reply(200, { output })
|
|
25
|
+
.post('/v1/responses', body => { requests.push(body); return true; })
|
|
26
|
+
.reply(200, { output: [{ type: 'message', content: [{ type: 'output_text', text: 'Done.' }] }] });
|
|
27
|
+
try {
|
|
28
|
+
const model = ModelMix.new({ config: { bottleneck: { minTime: 0 } } }).gpt6astra()
|
|
29
|
+
.addTool({ name: 'local_tool', description: 'Local tool', inputSchema: { type: 'object' } }, input => `value=${input.value}`)
|
|
30
|
+
.use(await skills({ paths: [path.join(__dirname, '../skills/modelmix')] }))
|
|
31
|
+
.addText('Use the modelmix skill.');
|
|
32
|
+
expect(await model.message()).to.equal('Done.');
|
|
33
|
+
expect(scope.isDone()).to.equal(true);
|
|
34
|
+
for (const request of requests) {
|
|
35
|
+
expect(request.tools.map(tool => tool.name)).to.have.members(['local_tool', 'read_skill']);
|
|
36
|
+
const skillTool = request.tools.find(tool => tool.name === 'read_skill');
|
|
37
|
+
expect(skillTool.strict).to.equal(false);
|
|
38
|
+
expect(skillTool.parameters.required).to.deep.equal(['name']);
|
|
39
|
+
}
|
|
40
|
+
expect(requests[1].input.slice(2, 6)).to.deep.equal(output);
|
|
41
|
+
const results = requests[1].input.filter(item => item.type === 'function_call_output');
|
|
42
|
+
expect(results.map(item => item.call_id)).to.deep.equal(['call_skill', 'call_local']);
|
|
43
|
+
expect(JSON.parse(results[0].output).content).to.include('name: modelmix');
|
|
44
|
+
expect(results[1].output).to.equal('value=7');
|
|
45
|
+
} finally {
|
|
46
|
+
nock.cleanAll();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('converts neutral tool history and native Responses tool options', () => {
|
|
51
|
+
const request = MixOpenAIResponses.buildResponsesRequest({
|
|
52
|
+
tools: [{ type: 'web_search' }, { type: 'function', function: { name: 'lookup', parameters: { type: 'object' }, strict: true } }],
|
|
53
|
+
tool_choice: { type: 'function', function: { name: 'lookup' } },
|
|
54
|
+
parallel_tool_calls: false,
|
|
55
|
+
messages: [
|
|
56
|
+
{ role: 'assistant', content: 'Checking.', tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'lookup', arguments: '{"query":"test"}' } }] },
|
|
57
|
+
{ role: 'tool', tool_call_id: 'call_1', content: 'Found.' }
|
|
58
|
+
]
|
|
59
|
+
});
|
|
60
|
+
expect(request.tools).to.deep.equal([{ type: 'web_search' }, { type: 'function', name: 'lookup', parameters: { type: 'object' }, strict: true }]);
|
|
61
|
+
expect(request.tool_choice).to.deep.equal({ type: 'function', name: 'lookup' });
|
|
62
|
+
expect(request.parallel_tool_calls).to.equal(false);
|
|
63
|
+
expect(request.input).to.deep.equal([
|
|
64
|
+
{ role: 'assistant', content: [{ type: 'output_text', text: 'Checking.' }] },
|
|
65
|
+
{ type: 'function_call', call_id: 'call_1', name: 'lookup', arguments: '{"query":"test"}' },
|
|
66
|
+
{ type: 'function_call_output', call_id: 'call_1', output: 'Found.' }
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
for (const Provider of [MixCustom, MixAnthropic, MixGoogle]) {
|
|
71
|
+
for (const hasExplicitTool of [false, true]) {
|
|
72
|
+
it(`preserves plugin and registered tools with ${Provider.name} options.tools (${hasExplicitTool ? 'populated' : 'empty'})`, async () => {
|
|
73
|
+
const registered = { name: 'registered', description: 'Registered tool', inputSchema: { type: 'object' } };
|
|
74
|
+
const extra = { ...registered, name: 'explicit' };
|
|
75
|
+
const provider = new Provider();
|
|
76
|
+
const explicitTools = hasExplicitTool ? provider.getOptionsTools({ local: [extra] }).tools : [];
|
|
77
|
+
let received;
|
|
78
|
+
provider.create = async ({ options }) => { received = options.tools; return { message: 'done', toolCalls: [] }; };
|
|
79
|
+
const model = ModelMix.new({ options: { tools: explicitTools } }).attach('custom', provider)
|
|
80
|
+
.addTool(registered, () => 'registered')
|
|
81
|
+
.use(await skills({ paths: [path.join(__dirname, '../skills/modelmix')] })).addText('Use skills');
|
|
82
|
+
await model.message();
|
|
83
|
+
const names = received.flatMap(tool => tool.functionDeclarations || [tool.function || tool]).map(tool => tool.name);
|
|
84
|
+
expect(names).to.have.members(['registered', 'read_skill', ...(hasExplicitTool ? ['explicit'] : [])]);
|
|
85
|
+
expect(model.options.tools).to.deep.equal(explicitTools);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
it('rejects collisions between explicit options and plugin tools before calling the provider', async () => {
|
|
91
|
+
let calls = 0;
|
|
92
|
+
const model = ModelMix.new({ options: { tools: [{ type: 'function', function: { name: 'read_skill' } }] } })
|
|
93
|
+
.attach('custom', createProvider(async () => { calls++; return { message: 'unexpected' }; }))
|
|
94
|
+
.use(await skills({ paths: [path.join(__dirname, '../skills/modelmix')] })).addText('test');
|
|
95
|
+
let failure;
|
|
96
|
+
try { await model.message(); } catch (error) { failure = error; }
|
|
97
|
+
expect(failure?.message).to.include('Duplicate tool name: read_skill');
|
|
98
|
+
expect(calls).to.equal(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('keeps plugin tools request-scoped across tool continuations and alongside local tools', async () => {
|
|
102
|
+
const requests = [];
|
|
103
|
+
const signal = new AbortController().signal;
|
|
104
|
+
const provider = createProvider(async request => {
|
|
105
|
+
requests.push(request);
|
|
106
|
+
if (requests.length === 1) return {
|
|
107
|
+
message: '',
|
|
108
|
+
toolCalls: [
|
|
109
|
+
{ id: 'skill', name: 'read_skill', input: {} },
|
|
110
|
+
{ id: 'local', name: 'local_tool', input: {} }
|
|
111
|
+
]
|
|
112
|
+
};
|
|
113
|
+
return { message: 'done', toolCalls: [] };
|
|
114
|
+
});
|
|
115
|
+
const model = ModelMix.new().attach('custom', provider)
|
|
116
|
+
.addTool({ name: 'local_tool', description: 'Local tool', inputSchema: { type: 'object' } }, () => 'local')
|
|
117
|
+
.use({
|
|
118
|
+
name: 'skills-test',
|
|
119
|
+
async execute(context, next) {
|
|
120
|
+
context.request.tools.push({
|
|
121
|
+
tool: { name: 'read_skill', description: 'Read skill', inputSchema: { type: 'object' } },
|
|
122
|
+
callback: (_args, callbackSignal) => {
|
|
123
|
+
expect(callbackSignal).to.equal(signal);
|
|
124
|
+
return 'skill instructions';
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
return next();
|
|
128
|
+
}
|
|
129
|
+
}).addText('Use both tools');
|
|
130
|
+
|
|
131
|
+
expect(await model.message(signal)).to.equal('done');
|
|
132
|
+
expect(requests).to.have.length(2);
|
|
133
|
+
for (const request of requests) {
|
|
134
|
+
expect(request.options.tools.map(tool => tool.function.name)).to.have.members(['local_tool', 'read_skill']);
|
|
135
|
+
}
|
|
136
|
+
const results = requests[1].options.messages.filter(message => message.role === 'tool');
|
|
137
|
+
expect(results.map(result => result.content)).to.deep.equal(['skill instructions', 'local']);
|
|
138
|
+
expect(model.mcpToolsManager.hasTool('read_skill')).to.equal(false);
|
|
139
|
+
expect(model.tools.local.map(tool => tool.name)).to.deep.equal(['local_tool']);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('rejects collisions between plugin tools and existing tools before calling a provider', async () => {
|
|
143
|
+
let calls = 0;
|
|
144
|
+
const tool = { name: 'same', description: 'Same tool', inputSchema: { type: 'object' } };
|
|
145
|
+
const model = ModelMix.new().attach('custom', createProvider(async () => {
|
|
146
|
+
calls += 1;
|
|
147
|
+
return { message: 'unexpected' };
|
|
148
|
+
})).addTool(tool, () => 'local').use({
|
|
149
|
+
name: 'collision',
|
|
150
|
+
async execute(context, next) {
|
|
151
|
+
context.request.tools.push({ tool, callback: () => 'plugin' });
|
|
152
|
+
return next();
|
|
153
|
+
}
|
|
154
|
+
}).addText('test');
|
|
155
|
+
let failure;
|
|
156
|
+
try { await model.message(); } catch (error) { failure = error; }
|
|
157
|
+
expect(failure?.message).to.include('Duplicate tool name: same');
|
|
158
|
+
expect(calls).to.equal(0);
|
|
159
|
+
});
|
|
160
|
+
|
|
12
161
|
it('keeps registration instance-scoped and lets new instances inherit plugins without history', () => {
|
|
13
162
|
const plugin = { name: 'metrics', execute: (_context, next) => next() };
|
|
14
163
|
const parent = ModelMix.new().use(plugin).addText('parent history');
|
package/test/public-api.test.js
CHANGED
|
@@ -8,6 +8,7 @@ describe('public module boundary', () => {
|
|
|
8
8
|
'MixAnthropic',
|
|
9
9
|
'MixCerebras',
|
|
10
10
|
'MixCustom',
|
|
11
|
+
'MixDeepSeek',
|
|
11
12
|
'MixFireworks',
|
|
12
13
|
'MixGoogle',
|
|
13
14
|
'MixGrok',
|
|
@@ -56,6 +57,7 @@ describe('public module boundary', () => {
|
|
|
56
57
|
['Gemini', config => new api.MixGoogle({ config })],
|
|
57
58
|
['MiniMax', config => new api.MixMiniMax({ config })],
|
|
58
59
|
['MiMo', config => new api.MixMiMo({ config })],
|
|
60
|
+
['DeepSeek', config => new api.MixDeepSeek({ config })],
|
|
59
61
|
['Perplexity', config => new api.MixPerplexity({ config })],
|
|
60
62
|
['Grok', config => new api.MixGrok({ config })],
|
|
61
63
|
['Lambda', config => new api.MixLambda({ config })],
|
package/test/tokens.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { expect } from 'chai';
|
|
2
|
-
import { ModelMix, MixAnthropic, MixCustom, MixGoogle, MixMiMo, MixOpenAI, MixOpenAIResponses, MixOpenRouter } from '../index.js';
|
|
2
|
+
import { ModelMix, MixAnthropic, MixCustom, MixGoogle, MixGrok, MixMiMo, MixOpenAI, MixOpenAIResponses, MixOpenRouter } from '../index.js';
|
|
3
3
|
import { createRequire } from 'module';
|
|
4
4
|
|
|
5
5
|
const require = createRequire(import.meta.url);
|
|
@@ -7,6 +7,78 @@ const nock = require('nock');
|
|
|
7
7
|
|
|
8
8
|
describe('Token Usage Tracking', () => {
|
|
9
9
|
|
|
10
|
+
it('adds native Grok reasoning to its exclusive completion count', () => {
|
|
11
|
+
const tokens = MixGrok.extractTokens({ usage: {
|
|
12
|
+
prompt_tokens: 656, completion_tokens: 2, total_tokens: 824,
|
|
13
|
+
prompt_tokens_details: { cached_tokens: 512 },
|
|
14
|
+
completion_tokens_details: { reasoning_tokens: 166 }
|
|
15
|
+
} });
|
|
16
|
+
expect(tokens).to.include({ input: 656, output: 2, thinking: 166, total: 824 });
|
|
17
|
+
expect(ModelMix.calculateCostBreakdown('grok-4.6', tokens).total).to.equal(0.001552);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('combines Anthropic stream input usage with the final thinking breakdown', async () => {
|
|
21
|
+
const { Readable } = require('node:stream');
|
|
22
|
+
const chunks = [
|
|
23
|
+
{ type: 'message_start', message: { usage: { input_tokens: 25, output_tokens: 1, cache_read_input_tokens: 10 } } },
|
|
24
|
+
{ type: 'message_delta', usage: { output_tokens: 348, output_tokens_details: { thinking_tokens: 312 } } },
|
|
25
|
+
{ type: 'message_stop' }
|
|
26
|
+
];
|
|
27
|
+
const result = await new MixAnthropic().processStream({ data: Readable.from(chunks.map(chunk => `data: ${JSON.stringify(chunk)}\n\n`)) });
|
|
28
|
+
expect(result.tokens).to.include({ input: 35, output: 36, thinking: 312, total: 383, cached: 10 });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('preserves Gemini stream thinking usage from the final metadata', async () => {
|
|
32
|
+
const { Readable } = require('node:stream');
|
|
33
|
+
const data = { usageMetadata: { promptTokenCount: 22, candidatesTokenCount: 5, thoughtsTokenCount: 99, totalTokenCount: 126 } };
|
|
34
|
+
const result = await new MixGoogle().processStream({ data: Readable.from([`data: ${JSON.stringify(data)}\n\n`]) });
|
|
35
|
+
expect(result.tokens).to.include({ input: 22, output: 5, thinking: 99, total: 126 });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
for (const [name, extract, usage] of [
|
|
39
|
+
['Chat Completions', MixCustom.extractTokens, { prompt_tokens: 615, completion_tokens: 5930, completion_tokens_details: { reasoning_tokens: 5900 } }],
|
|
40
|
+
['Responses', MixOpenAIResponses.extractResponsesTokens, { input_tokens: 615, output_tokens: 5930, output_tokens_details: { reasoning_tokens: 5900 } }],
|
|
41
|
+
['Anthropic', MixAnthropic.extractTokens, { input_tokens: 615, output_tokens: 5930, output_tokens_details: { thinking_tokens: 5900 } }]
|
|
42
|
+
]) {
|
|
43
|
+
it(`separates ${name} reasoning without billing it twice`, () => {
|
|
44
|
+
const tokens = extract({ usage });
|
|
45
|
+
expect(tokens).to.include({ input: 615, output: 30, thinking: 5900, total: 6545 });
|
|
46
|
+
expect(ModelMix.calculateCostBreakdown('deepseek/deepseek-v4.1-flash', tokens).total).to.equal(0.00365025);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
it('keeps streamed OpenRouter usage and actual cost through usage-only and trailing chunks', async () => {
|
|
51
|
+
const { Readable } = require('node:stream');
|
|
52
|
+
const provider = new MixOpenRouter();
|
|
53
|
+
const chunks = [
|
|
54
|
+
{ choices: [{ delta: { reasoning: 'thinking' }, finish_reason: null }] },
|
|
55
|
+
{ choices: [{ delta: { content: 'ok' }, finish_reason: 'stop' }] },
|
|
56
|
+
{ choices: [], usage: { prompt_tokens: 10, completion_tokens: 12, completion_tokens_details: { reasoning_tokens: 10 }, cost: 0.123 } },
|
|
57
|
+
{ choices: [] }
|
|
58
|
+
];
|
|
59
|
+
provider.create = async () => provider.processStream({ data: Readable.from(chunks.map(chunk => `data: ${JSON.stringify(chunk)}\n\n`)) });
|
|
60
|
+
const model = ModelMix.new().attach('unlisted-model', provider).addText('test');
|
|
61
|
+
const deltas = [];
|
|
62
|
+
const result = await model.stream(({ delta }) => deltas.push(delta));
|
|
63
|
+
expect(deltas.join('')).to.equal('ok');
|
|
64
|
+
expect(result.tokens).to.include({ input: 10, output: 2, thinking: 10, total: 22, cost: 0.123 });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
for (const cost of [0, 0.0073005]) {
|
|
68
|
+
it(`uses the reported OpenRouter cost ${cost} instead of catalog pricing`, async () => {
|
|
69
|
+
const provider = new MixOpenRouter();
|
|
70
|
+
provider.create = async () => provider.processResponse({ data: {
|
|
71
|
+
choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }],
|
|
72
|
+
usage: { prompt_tokens: 615, completion_tokens: 5930, completion_tokens_details: { reasoning_tokens: 5900 }, cost }
|
|
73
|
+
} });
|
|
74
|
+
const model = ModelMix.new().attach('deepseek/deepseek-v4.1-flash', provider).addText('test');
|
|
75
|
+
const result = await model.raw();
|
|
76
|
+
expect(result.tokens.cost).to.equal(cost);
|
|
77
|
+
expect(result.tokens).to.include({ output: 30, thinking: 5900 });
|
|
78
|
+
expect(model.lastRaw.tokens.cost).to.equal(cost);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
10
82
|
// Ensure nock doesn't interfere with live requests via MockHttpSocket
|
|
11
83
|
before(function() {
|
|
12
84
|
nock.cleanAll();
|
|
@@ -691,7 +763,7 @@ describe('Token Usage Tracking', () => {
|
|
|
691
763
|
|
|
692
764
|
expect(result.tokens.input).to.be.greaterThan(0);
|
|
693
765
|
expect(result.tokens.output).to.be.greaterThan(0);
|
|
694
|
-
expect(result.tokens.total).to.equal(result.tokens.input + result.tokens.output);
|
|
766
|
+
expect(result.tokens.total).to.equal(result.tokens.input + result.tokens.output + result.tokens.thinking);
|
|
695
767
|
});
|
|
696
768
|
|
|
697
769
|
it('should track tokens in Google Gemini response', async function () {
|
|
@@ -735,7 +807,7 @@ describe('Token Usage Tracking', () => {
|
|
|
735
807
|
expect(result2.tokens.output).to.be.greaterThan(0);
|
|
736
808
|
|
|
737
809
|
// Verify both results have valid token counts
|
|
738
|
-
expect(result1.tokens.total).to.equal(result1.tokens.input + result1.tokens.output);
|
|
810
|
+
expect(result1.tokens.total).to.equal(result1.tokens.input + result1.tokens.output + result1.tokens.thinking);
|
|
739
811
|
expect(result2.tokens.total).to.be.greaterThan(0);
|
|
740
812
|
});
|
|
741
813
|
|