modelmix 5.1.19 → 5.2.0
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 +45 -3
- 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 +3 -0
- package/effort.js +4 -1
- package/index.d.ts +9 -0
- package/index.js +33 -5
- package/lib/model-chain.js +2 -2
- 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-options.js +1 -1
- package/lib/providers/openai.js +5 -1
- package/lib/token-usage.js +10 -4
- package/package.json +5 -5
- 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/skills/modelmix/SKILL.md +14 -5
- package/test/deepseek.test.js +273 -1
- package/test/effort.test.js +9 -0
- package/test/fallback.test.js +97 -5
- package/test/google.test.js +55 -0
- package/test/history.test.js +7 -4
- package/test/json.test.js +29 -1
- package/test/public-api.test.js +2 -0
- package/test/tokens.test.js +83 -3
- package/RLM_PLUGIN_SPEC.md +0 -465
- package/demo/package-lock.json +0 -516
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
const { expect } = require('chai');
|
|
2
|
+
const sinon = require('sinon');
|
|
3
|
+
|
|
4
|
+
const { MixOpenAI, ModelMix } = require('../../..');
|
|
5
|
+
const { benchmark } = require('..');
|
|
6
|
+
|
|
7
|
+
const criteria = {
|
|
8
|
+
criteria: [
|
|
9
|
+
{ id: 'correctness', description: 'How completely the response fulfills the task.' },
|
|
10
|
+
{ id: 'clarity', description: 'How clear and coherent the response is.' }
|
|
11
|
+
]
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function request(overrides = {}) {
|
|
15
|
+
return {
|
|
16
|
+
system: 'Original system instructions',
|
|
17
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: 'Complete the task.' }] }],
|
|
18
|
+
options: { max_tokens: 123, response_format: { type: 'json_object' } },
|
|
19
|
+
config: { system: 'Original system instructions' },
|
|
20
|
+
outputMode: 'json',
|
|
21
|
+
...overrides
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function modelKey(input) {
|
|
26
|
+
return input.model.models[0].key;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function validEvaluation(correctness = 8, clarity = 6) {
|
|
30
|
+
return JSON.stringify({
|
|
31
|
+
scores: [
|
|
32
|
+
{ criterionId: 'correctness', score: correctness, justification: 'It fulfills the task.' },
|
|
33
|
+
{ criterionId: 'clarity', score: clarity, justification: 'It is easy to follow.' }
|
|
34
|
+
]
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function metricsResult(message, cost = 0.01) {
|
|
39
|
+
return {
|
|
40
|
+
message,
|
|
41
|
+
tokens: { input: 1, output: 2, total: 3, cost }
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function directContext(invoke, overrides = {}) {
|
|
46
|
+
return {
|
|
47
|
+
request: request(overrides),
|
|
48
|
+
execution: { executionId: 'root', parentExecutionId: null, depth: 0 },
|
|
49
|
+
signal: overrides.signal,
|
|
50
|
+
invoke
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('Empty benchmark responses', () => {
|
|
55
|
+
it('records a generation failure and never evaluates an empty candidate', async () => {
|
|
56
|
+
const plugin = benchmark({ criteriaModel: 'gpt5nano', models: ['gpt5', 'gpt5mini'] });
|
|
57
|
+
const result = await plugin.execute(directContext(async input => {
|
|
58
|
+
if (input.system.includes('define evaluation criteria')) return metricsResult(JSON.stringify(criteria));
|
|
59
|
+
if (input.system.includes('evaluate one candidate response')) {
|
|
60
|
+
expect(input.messages[0].content).to.include('usable response');
|
|
61
|
+
return metricsResult(validEvaluation());
|
|
62
|
+
}
|
|
63
|
+
return metricsResult(modelKey(input) === 'gpt-5' ? ' ' : 'usable response');
|
|
64
|
+
}));
|
|
65
|
+
expect(result.benchmark.results[0]).to.include({ response: null, score: null });
|
|
66
|
+
expect(result.benchmark.results[0].evaluationCount).to.deep.equal({ expected: 0, valid: 0 });
|
|
67
|
+
expect(result.benchmark.errors[0]).to.include({ stage: 'response', participant: 'gpt5' });
|
|
68
|
+
expect(result.benchmark.errors[0].error.message).to.include('no text response');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
async function expectRejection(promise, message) {
|
|
73
|
+
let rejection;
|
|
74
|
+
try {
|
|
75
|
+
await promise;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
rejection = error;
|
|
78
|
+
}
|
|
79
|
+
expect(rejection).to.be.an('error');
|
|
80
|
+
expect(rejection.message).to.include(message);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('benchmark plugin', () => {
|
|
84
|
+
afterEach(() => sinon.restore());
|
|
85
|
+
|
|
86
|
+
it('uses the native DeepSeek provider for criteria, responses and judging when selected', async () => {
|
|
87
|
+
const { MixDeepSeek, MixOpenRouter } = require('../../..');
|
|
88
|
+
const originalApiKey = process.env.DEEPSEEK_API_KEY;
|
|
89
|
+
process.env.DEEPSEEK_API_KEY = 'native-test-key';
|
|
90
|
+
const stages = [];
|
|
91
|
+
try {
|
|
92
|
+
const plugin = benchmark({
|
|
93
|
+
criteriaModel: 'deepseekV41Flash@20',
|
|
94
|
+
models: ['deepseekV41Flash@60', 'gpt5'],
|
|
95
|
+
mix: { deepseek: true, openrouter: false }
|
|
96
|
+
});
|
|
97
|
+
const raw = await plugin.execute(directContext(async input => {
|
|
98
|
+
expect(input.model.models.some(item => item.provider instanceof MixOpenRouter)).to.equal(false);
|
|
99
|
+
const stage = input.system.includes('define evaluation criteria')
|
|
100
|
+
? 'criteria'
|
|
101
|
+
: input.system.includes('evaluate one candidate response') ? 'evaluation' : 'response';
|
|
102
|
+
if (modelKey(input) === 'deepseek-flash') {
|
|
103
|
+
expect(input.model.models).to.have.length(1);
|
|
104
|
+
const { provider } = input.model.models[0];
|
|
105
|
+
expect(provider).to.be.instanceOf(MixDeepSeek);
|
|
106
|
+
expect(provider.config.url).to.equal('https://api.deepseek.com/chat/completions');
|
|
107
|
+
expect(provider.config.effort).to.equal(stage === 'criteria' ? 20 : 60);
|
|
108
|
+
stages.push(stage);
|
|
109
|
+
}
|
|
110
|
+
if (stage === 'criteria') return metricsResult(JSON.stringify(criteria));
|
|
111
|
+
if (stage === 'evaluation') return metricsResult(validEvaluation());
|
|
112
|
+
return metricsResult('Candidate answer');
|
|
113
|
+
}));
|
|
114
|
+
expect(stages).to.deep.equal(['criteria', 'response', 'evaluation']);
|
|
115
|
+
expect(raw.benchmark.errors).to.deep.equal([]);
|
|
116
|
+
expect(raw.benchmark.results[0].canonicalModel).to.equal('deepseek-flash');
|
|
117
|
+
} finally {
|
|
118
|
+
if (originalApiKey === undefined) delete process.env.DEEPSEEK_API_KEY;
|
|
119
|
+
else process.env.DEEPSEEK_API_KEY = originalApiKey;
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('accepts a closing Markdown delimiter on criteria and evaluations without changing the response', async () => {
|
|
124
|
+
const response = 'Candidate with ```text\ncontent\n```';
|
|
125
|
+
const plugin = benchmark({ criteriaModel: 'gpt5nano', models: ['gpt5', 'sonnet5'] });
|
|
126
|
+
const raw = await plugin.execute(directContext(async input => {
|
|
127
|
+
if (input.system.includes('define evaluation criteria')) {
|
|
128
|
+
return metricsResult(JSON.stringify(criteria) + '```');
|
|
129
|
+
}
|
|
130
|
+
if (input.system.includes('evaluate one candidate response')) {
|
|
131
|
+
return metricsResult(validEvaluation() + '\n```\n');
|
|
132
|
+
}
|
|
133
|
+
return metricsResult(response);
|
|
134
|
+
}));
|
|
135
|
+
expect(raw.benchmark.errors).to.deep.equal([]);
|
|
136
|
+
expect(raw.benchmark.results[0].response).to.equal(response);
|
|
137
|
+
expect(raw.benchmark.results.map(result => result.score)).to.deep.equal([7, 7]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('still rejects invalid or incomplete evaluations with closing Markdown delimiters', async () => {
|
|
141
|
+
const plugin = benchmark({ criteriaModel: 'gpt5nano', models: ['gpt5', 'sonnet5'] });
|
|
142
|
+
let evaluation = 0;
|
|
143
|
+
const invalid = [validEvaluation() + ' explanation```', '{"scores":[]}```'];
|
|
144
|
+
const raw = await plugin.execute(directContext(async input => {
|
|
145
|
+
if (input.system.includes('define evaluation criteria')) {
|
|
146
|
+
return metricsResult(JSON.stringify(criteria));
|
|
147
|
+
}
|
|
148
|
+
if (input.system.includes('evaluate one candidate response')) {
|
|
149
|
+
return metricsResult(invalid[evaluation++]);
|
|
150
|
+
}
|
|
151
|
+
return metricsResult('Candidate');
|
|
152
|
+
}));
|
|
153
|
+
expect(raw.benchmark.results.map(result => result.score)).to.deep.equal([null, null]);
|
|
154
|
+
expect(raw.benchmark.errors.map(item => item.error.details.outputText)).to.deep.equal(invalid);
|
|
155
|
+
expect(raw.benchmark.errors[0].error.message).to.include('invalid JSON');
|
|
156
|
+
expect(raw.benchmark.errors[1].error.message).to.include('every criterion exactly once');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
for (const failJudge of [false, true]) {
|
|
160
|
+
it(`runs Gemini through its adapter and ${failJudge ? 'preserves provider failures in logs and the report' : 'keeps JSON mode limited to criteria and judging'}`, async () => {
|
|
161
|
+
const log = sinon.stub(console, 'log');
|
|
162
|
+
const bodies = [];
|
|
163
|
+
const providerError = 'Invalid JSON payload received. Unknown name "content".';
|
|
164
|
+
sinon.stub(global, 'fetch').callsFake(async (_url, input) => {
|
|
165
|
+
const body = JSON.parse(input.body);
|
|
166
|
+
bodies.push(body);
|
|
167
|
+
const system = body.systemInstruction.parts.map(part => part.text).join('');
|
|
168
|
+
const judging = system.includes('evaluate one candidate response');
|
|
169
|
+
if (judging && failJudge) {
|
|
170
|
+
return new Response(JSON.stringify({ error: { message: providerError } }), { status: 400 });
|
|
171
|
+
}
|
|
172
|
+
const text = system.includes('define evaluation criteria')
|
|
173
|
+
? JSON.stringify(criteria)
|
|
174
|
+
: judging ? validEvaluation() : 'Gemini candidate';
|
|
175
|
+
return new Response(JSON.stringify({
|
|
176
|
+
candidates: [{ content: { parts: [{ text }] }, finishReason: 'STOP' }]
|
|
177
|
+
}), { status: 200 });
|
|
178
|
+
});
|
|
179
|
+
sinon.stub(MixOpenAI.prototype, 'create').callsFake(async ({ config }) => (
|
|
180
|
+
metricsResult(config.system.includes('evaluate one candidate response')
|
|
181
|
+
? validEvaluation() : 'Other candidate')
|
|
182
|
+
));
|
|
183
|
+
const report = await ModelMix.new({ config: { apiKey: 'test-key', debug: 1 } })
|
|
184
|
+
.use(benchmark({ criteriaModel: 'gemini38flash@20', models: ['gemini38flash@20', 'gpt5mini@20'] }))
|
|
185
|
+
.addText('Complete the task.')
|
|
186
|
+
.json();
|
|
187
|
+
|
|
188
|
+
expect(bodies).to.have.length(3);
|
|
189
|
+
expect(bodies.map(body => body.generationConfig.responseMimeType)).to.deep.equal([
|
|
190
|
+
'application/json', 'text/plain', 'application/json'
|
|
191
|
+
]);
|
|
192
|
+
for (const body of bodies) {
|
|
193
|
+
expect(body.contents).to.have.length(1);
|
|
194
|
+
expect(body.contents[0]).to.have.keys('role', 'parts');
|
|
195
|
+
expect(body.contents[0].parts[0].text).to.be.a('string');
|
|
196
|
+
}
|
|
197
|
+
expect(report.results[0].response).to.equal('Gemini candidate');
|
|
198
|
+
expect(report.results[0].score).to.equal(7);
|
|
199
|
+
expect(report.metrics.calls.attempted).to.equal(5);
|
|
200
|
+
expect(report.errors).to.have.length(failJudge ? 1 : 0);
|
|
201
|
+
expect(report.results[1].score).to.equal(failJudge ? null : 7);
|
|
202
|
+
if (failJudge) {
|
|
203
|
+
expect(report.errors[0]).to.include({ stage: 'evaluation', judge: 'gemini38flash@20' });
|
|
204
|
+
expect(report.errors[0].error.statusCode).to.equal(400);
|
|
205
|
+
expect(report.errors[0].error.details.error.message).to.equal(providerError);
|
|
206
|
+
expect(log.args.flat().join('\n')).to.include(providerError).and.include('HTTP 400');
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
it('preserves Markdown fences inside valid JSON evaluations and the final JSON report', async () => {
|
|
212
|
+
sinon.stub(MixOpenAI.prototype, 'create').callsFake(async ({ config }) => {
|
|
213
|
+
if (config.system.includes('define evaluation criteria')) {
|
|
214
|
+
return metricsResult(JSON.stringify(criteria));
|
|
215
|
+
}
|
|
216
|
+
if (config.system.includes('evaluate one candidate response')) {
|
|
217
|
+
const value = JSON.parse(validEvaluation());
|
|
218
|
+
value.scores[0].justification = 'The response contains ```text and ``` markers.';
|
|
219
|
+
return metricsResult(JSON.stringify(value));
|
|
220
|
+
}
|
|
221
|
+
return metricsResult('```text\nA candidate answer\n```');
|
|
222
|
+
});
|
|
223
|
+
const report = await ModelMix.new().use(benchmark({
|
|
224
|
+
criteriaModel: 'gpt5nano', models: ['gpt5', 'gpt5mini']
|
|
225
|
+
})).addText('Evaluate the answer.').json();
|
|
226
|
+
expect(report.errors).to.deep.equal([]);
|
|
227
|
+
expect(report.results[0].response).to.equal('```text\nA candidate answer\n```');
|
|
228
|
+
expect(report.results[0].evaluationCount.valid).to.equal(1);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('rejects truncated output with its finish reason and preserves the text and metrics', async () => {
|
|
232
|
+
const plugin = benchmark({ criteriaModel: 'gpt5nano', models: ['gpt5', 'sonnet5'] });
|
|
233
|
+
const log = sinon.stub(console, 'log');
|
|
234
|
+
const raw = await plugin.execute(directContext(async input => {
|
|
235
|
+
if (input.system.includes('define evaluation criteria')) {
|
|
236
|
+
return metricsResult(JSON.stringify(criteria));
|
|
237
|
+
}
|
|
238
|
+
if (input.system.includes('evaluate one candidate response')) {
|
|
239
|
+
return {
|
|
240
|
+
...metricsResult('{"scores":['),
|
|
241
|
+
response: { choices: [{ finish_reason: 'length' }] }
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
return metricsResult('Candidate answer');
|
|
245
|
+
}, { config: { debug: 1 } }));
|
|
246
|
+
expect(raw.benchmark.errors).to.have.length(2);
|
|
247
|
+
expect(raw.benchmark.errors[0].error.message).to.include('token limit');
|
|
248
|
+
expect(raw.benchmark.errors[0].error.details).to.include({
|
|
249
|
+
finishReason: 'length', outputText: '{"scores":['
|
|
250
|
+
});
|
|
251
|
+
expect(raw.benchmark.metrics.calls.attempted).to.equal(5);
|
|
252
|
+
expect(raw.benchmark.metrics.cost).to.be.closeTo(0.05, 1e-12);
|
|
253
|
+
expect(raw.benchmark.results[0].score).to.equal(null);
|
|
254
|
+
expect(log.args.flat().join('\n')).to.include('token limit');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('returns a JSON report with isolated responses, cross-evaluations, averages, and metrics', async () => {
|
|
258
|
+
const calls = [];
|
|
259
|
+
const plugin = benchmark({
|
|
260
|
+
criteriaModel: 'gpt5nano@10',
|
|
261
|
+
models: ['gpt5@0', 'gpt5mini@25', 'sonnet5@50']
|
|
262
|
+
});
|
|
263
|
+
const context = directContext(async input => {
|
|
264
|
+
calls.push(input);
|
|
265
|
+
if (input.system.includes('define evaluation criteria')) {
|
|
266
|
+
return metricsResult(JSON.stringify(criteria));
|
|
267
|
+
}
|
|
268
|
+
if (input.system.includes('evaluate one candidate response')) {
|
|
269
|
+
return metricsResult(validEvaluation());
|
|
270
|
+
}
|
|
271
|
+
return metricsResult(`response:${modelKey(input)}`);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const raw = await plugin.execute(context);
|
|
275
|
+
const report = JSON.parse(raw.message);
|
|
276
|
+
|
|
277
|
+
expect(raw.benchmark).to.deep.equal(report);
|
|
278
|
+
expect(report.criteria.items).to.deep.equal(criteria.criteria);
|
|
279
|
+
expect(report.criteria.model).to.include({ id: 'gpt5nano@10', effort: 10 });
|
|
280
|
+
expect(report.results).to.have.length(3);
|
|
281
|
+
expect(report.results.map(result => result.response)).to.deep.equal([
|
|
282
|
+
'response:gpt-5',
|
|
283
|
+
'response:gpt-5-mini',
|
|
284
|
+
'response:claude-sonnet-5'
|
|
285
|
+
]);
|
|
286
|
+
for (const result of report.results) {
|
|
287
|
+
expect(result.evaluationCount).to.deep.equal({ expected: 2, valid: 2 });
|
|
288
|
+
expect(result.averages).to.deep.equal([
|
|
289
|
+
{ criterionId: 'correctness', score: 8 },
|
|
290
|
+
{ criterionId: 'clarity', score: 6 }
|
|
291
|
+
]);
|
|
292
|
+
expect(result.score).to.equal(7);
|
|
293
|
+
}
|
|
294
|
+
expect(report.errors).to.deep.equal([]);
|
|
295
|
+
expect(report.metrics.tokens).to.include({ input: 10, output: 20, total: 30 });
|
|
296
|
+
expect(report.metrics.cost).to.be.closeTo(0.1, 1e-12);
|
|
297
|
+
expect(report.metrics.calls).to.deep.equal({ attempted: 10, withTokens: 10, withCost: 10 });
|
|
298
|
+
|
|
299
|
+
const participantCalls = calls.filter(call => call.system === request().system);
|
|
300
|
+
expect(participantCalls).to.have.length(3);
|
|
301
|
+
for (const call of participantCalls) {
|
|
302
|
+
expect(call.messages).to.deep.equal(request().messages);
|
|
303
|
+
expect(call.options).to.not.have.property('response_format');
|
|
304
|
+
expect(call.plugins).to.equal('none');
|
|
305
|
+
expect(call.history).to.equal(false);
|
|
306
|
+
}
|
|
307
|
+
for (const call of calls.filter(call => call.system !== request().system)) {
|
|
308
|
+
expect(call.options.response_format).to.deep.equal({ type: 'json_object' });
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('treats aliases as one model for duplicates and self-evaluation exclusion', async () => {
|
|
313
|
+
const plugin = benchmark({
|
|
314
|
+
criteriaModel: 'gpt5nano',
|
|
315
|
+
models: ['sonnet5@10', 'sonnet50@20', 'gpt5@30']
|
|
316
|
+
});
|
|
317
|
+
const raw = await plugin.execute(directContext(async input => {
|
|
318
|
+
if (input.system.includes('define evaluation criteria')) {
|
|
319
|
+
return metricsResult(JSON.stringify(criteria));
|
|
320
|
+
}
|
|
321
|
+
if (input.system.includes('evaluate one candidate response')) {
|
|
322
|
+
return metricsResult(validEvaluation());
|
|
323
|
+
}
|
|
324
|
+
return metricsResult(`response:${modelKey(input)}`);
|
|
325
|
+
}));
|
|
326
|
+
|
|
327
|
+
expect(raw.benchmark.results.map(result => result.evaluationCount)).to.deep.equal([
|
|
328
|
+
{ expected: 1, valid: 1 },
|
|
329
|
+
{ expected: 1, valid: 1 },
|
|
330
|
+
{ expected: 2, valid: 2 }
|
|
331
|
+
]);
|
|
332
|
+
expect(raw.benchmark.results[0].evaluations[0].judge.canonicalModel).to.equal('gpt-5');
|
|
333
|
+
expect(raw.benchmark.results[1].evaluations[0].judge.canonicalModel).to.equal('gpt-5');
|
|
334
|
+
expect(raw.benchmark.results[2].evaluations.map(item => item.judge.id)).to.deep.equal([
|
|
335
|
+
'sonnet5@10',
|
|
336
|
+
'sonnet50@20'
|
|
337
|
+
]);
|
|
338
|
+
|
|
339
|
+
const duplicate = benchmark({
|
|
340
|
+
criteriaModel: 'gpt5nano',
|
|
341
|
+
models: ['sonnet5@100', 'sonnet50@100', 'gpt5']
|
|
342
|
+
});
|
|
343
|
+
await expectRejection(duplicate.execute(directContext(async () => {
|
|
344
|
+
throw new Error('should not be called');
|
|
345
|
+
})), 'duplicates the same model and effective effort');
|
|
346
|
+
|
|
347
|
+
const inheritedDuplicate = benchmark({
|
|
348
|
+
criteriaModel: 'gpt5nano',
|
|
349
|
+
models: ['sonnet5', 'sonnet50', 'gpt5']
|
|
350
|
+
});
|
|
351
|
+
await expectRejection(inheritedDuplicate.execute(directContext(async () => {
|
|
352
|
+
throw new Error('should not be called');
|
|
353
|
+
}, {
|
|
354
|
+
config: { system: 'Original system instructions', effort: 40 }
|
|
355
|
+
})), 'duplicates the same model and effective effort');
|
|
356
|
+
|
|
357
|
+
const oneDistinctModel = benchmark({
|
|
358
|
+
criteriaModel: 'gpt5nano',
|
|
359
|
+
models: ['sonnet5@10', 'sonnet50@20']
|
|
360
|
+
});
|
|
361
|
+
await expectRejection(oneDistinctModel.execute(directContext(async () => {
|
|
362
|
+
throw new Error('should not be called');
|
|
363
|
+
})), 'at least two distinct models');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('keeps partial results and lets a failed participant continue as a judge', async () => {
|
|
367
|
+
const evaluationCalls = [];
|
|
368
|
+
const plugin = benchmark({
|
|
369
|
+
criteriaModel: 'gpt5nano',
|
|
370
|
+
models: ['gpt5', 'gpt5mini', 'sonnet5']
|
|
371
|
+
});
|
|
372
|
+
const raw = await plugin.execute(directContext(async input => {
|
|
373
|
+
if (input.system.includes('define evaluation criteria')) {
|
|
374
|
+
return metricsResult(JSON.stringify(criteria));
|
|
375
|
+
}
|
|
376
|
+
if (input.system.includes('evaluate one candidate response')) {
|
|
377
|
+
const payload = JSON.parse(input.messages[0].content);
|
|
378
|
+
const judge = modelKey(input);
|
|
379
|
+
evaluationCalls.push({ judge, response: payload.candidateResponse });
|
|
380
|
+
if (judge === 'claude-sonnet-5' && payload.candidateResponse === 'response:gpt-5-mini') {
|
|
381
|
+
return metricsResult('{invalid');
|
|
382
|
+
}
|
|
383
|
+
return metricsResult(validEvaluation(9, 7));
|
|
384
|
+
}
|
|
385
|
+
if (modelKey(input) === 'gpt-5') throw new Error('participant unavailable');
|
|
386
|
+
return metricsResult(`response:${modelKey(input)}`);
|
|
387
|
+
}));
|
|
388
|
+
|
|
389
|
+
const [failed, mini, sonnet] = raw.benchmark.results;
|
|
390
|
+
expect(failed.response).to.equal(null);
|
|
391
|
+
expect(failed.responseMetrics).to.include({ tokens: null, cost: null });
|
|
392
|
+
expect(failed.score).to.equal(null);
|
|
393
|
+
expect(failed.evaluationCount).to.deep.equal({ expected: 0, valid: 0 });
|
|
394
|
+
expect(mini.evaluationCount).to.deep.equal({ expected: 2, valid: 1 });
|
|
395
|
+
expect(sonnet.evaluationCount).to.deep.equal({ expected: 2, valid: 2 });
|
|
396
|
+
expect(sonnet.score).to.equal(8);
|
|
397
|
+
expect(evaluationCalls).to.deep.include({
|
|
398
|
+
judge: 'gpt-5',
|
|
399
|
+
response: 'response:gpt-5-mini'
|
|
400
|
+
});
|
|
401
|
+
expect(evaluationCalls).to.deep.include({
|
|
402
|
+
judge: 'gpt-5',
|
|
403
|
+
response: 'response:claude-sonnet-5'
|
|
404
|
+
});
|
|
405
|
+
expect(raw.benchmark.errors.map(error => error.stage)).to.deep.equal([
|
|
406
|
+
'response',
|
|
407
|
+
'evaluation'
|
|
408
|
+
]);
|
|
409
|
+
expect(raw.benchmark.errors[1].error.message).to.include('invalid JSON');
|
|
410
|
+
expect(raw.benchmark.metrics.calls).to.deep.equal({ attempted: 8, withTokens: 7, withCost: 7 });
|
|
411
|
+
expect(raw.benchmark.metrics.cost).to.be.closeTo(0.07, 1e-12);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('rejects incomplete evaluations without using any of their scores', async () => {
|
|
415
|
+
const plugin = benchmark({
|
|
416
|
+
criteriaModel: 'gpt5nano',
|
|
417
|
+
models: ['gpt5', 'sonnet5']
|
|
418
|
+
});
|
|
419
|
+
const raw = await plugin.execute(directContext(async input => {
|
|
420
|
+
if (input.system.includes('define evaluation criteria')) {
|
|
421
|
+
return metricsResult(JSON.stringify(criteria));
|
|
422
|
+
}
|
|
423
|
+
if (input.system.includes('evaluate one candidate response')) {
|
|
424
|
+
return metricsResult(JSON.stringify({
|
|
425
|
+
scores: [{
|
|
426
|
+
criterionId: 'correctness',
|
|
427
|
+
score: 10,
|
|
428
|
+
justification: 'Only one criterion was scored.'
|
|
429
|
+
}]
|
|
430
|
+
}));
|
|
431
|
+
}
|
|
432
|
+
return metricsResult(`response:${modelKey(input)}`);
|
|
433
|
+
}));
|
|
434
|
+
|
|
435
|
+
for (const result of raw.benchmark.results) {
|
|
436
|
+
expect(result.evaluations).to.deep.equal([]);
|
|
437
|
+
expect(result.averages).to.deep.equal([
|
|
438
|
+
{ criterionId: 'correctness', score: null },
|
|
439
|
+
{ criterionId: 'clarity', score: null }
|
|
440
|
+
]);
|
|
441
|
+
expect(result.score).to.equal(null);
|
|
442
|
+
}
|
|
443
|
+
expect(raw.benchmark.errors).to.have.length(2);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('aborts when criteria generation fails and propagates cancellation', async () => {
|
|
447
|
+
const invalidCriteria = benchmark({
|
|
448
|
+
criteriaModel: 'gpt5nano',
|
|
449
|
+
models: ['gpt5', 'sonnet5']
|
|
450
|
+
});
|
|
451
|
+
await expectRejection(invalidCriteria.execute(directContext(async () => (
|
|
452
|
+
metricsResult(JSON.stringify({ criteria: [] }))
|
|
453
|
+
))), 'criteria generation failed');
|
|
454
|
+
|
|
455
|
+
const controller = new AbortController();
|
|
456
|
+
controller.abort(new Error('cancel benchmark'));
|
|
457
|
+
let calls = 0;
|
|
458
|
+
await expectRejection(invalidCriteria.execute(directContext(async () => {
|
|
459
|
+
calls += 1;
|
|
460
|
+
return metricsResult(JSON.stringify(criteria));
|
|
461
|
+
}, { signal: controller.signal })), 'cancel benchmark');
|
|
462
|
+
expect(calls).to.equal(0);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('logs intermediate progress when ModelMix debug is enabled', async () => {
|
|
466
|
+
const plugin = benchmark({
|
|
467
|
+
criteriaModel: 'gpt5nano',
|
|
468
|
+
models: ['gpt5', 'sonnet5']
|
|
469
|
+
});
|
|
470
|
+
const log = sinon.stub(console, 'log');
|
|
471
|
+
try {
|
|
472
|
+
await plugin.execute(directContext(async input => {
|
|
473
|
+
if (input.system.includes('define evaluation criteria')) {
|
|
474
|
+
return metricsResult(JSON.stringify(criteria));
|
|
475
|
+
}
|
|
476
|
+
if (input.system.includes('evaluate one candidate response')) {
|
|
477
|
+
return metricsResult(validEvaluation());
|
|
478
|
+
}
|
|
479
|
+
return metricsResult(`response:${modelKey(input)}`);
|
|
480
|
+
}, {
|
|
481
|
+
config: { system: 'Original system instructions', debug: 1 }
|
|
482
|
+
}));
|
|
483
|
+
} finally {
|
|
484
|
+
log.restore();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const output = log.args.flat().join('\n');
|
|
488
|
+
expect(output).to.include('[benchmark] Generating criteria with gpt5nano.');
|
|
489
|
+
expect(output).to.include('[benchmark] Running response 1/2: gpt5.');
|
|
490
|
+
expect(output).to.include('[benchmark] Running evaluation 2/2:');
|
|
491
|
+
expect(output).to.include('[benchmark] Completed benchmark');
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('integrates with ModelMix json() and exposes the same report through lastRaw', async () => {
|
|
495
|
+
sinon.stub(MixOpenAI.prototype, 'create').callsFake(async ({ config, options }) => {
|
|
496
|
+
if (config.system.includes('define evaluation criteria')) {
|
|
497
|
+
return metricsResult(JSON.stringify(criteria));
|
|
498
|
+
}
|
|
499
|
+
if (config.system.includes('evaluate one candidate response')) {
|
|
500
|
+
return metricsResult(validEvaluation());
|
|
501
|
+
}
|
|
502
|
+
expect(options).to.not.have.property('response_format');
|
|
503
|
+
return metricsResult(`response:${options.model}`);
|
|
504
|
+
});
|
|
505
|
+
const model = ModelMix.new()
|
|
506
|
+
.use(benchmark({
|
|
507
|
+
criteriaModel: 'gpt5nano',
|
|
508
|
+
models: ['gpt5', 'gpt5mini']
|
|
509
|
+
}))
|
|
510
|
+
.addText('Complete the task.');
|
|
511
|
+
|
|
512
|
+
const report = await model.json();
|
|
513
|
+
|
|
514
|
+
expect(report.results).to.have.length(2);
|
|
515
|
+
expect(model.lastRaw.benchmark).to.deep.equal(report);
|
|
516
|
+
expect(JSON.parse(model.lastRaw.message)).to.deep.equal(report);
|
|
517
|
+
});
|
|
518
|
+
});
|
package/skills/modelmix/SKILL.md
CHANGED
|
@@ -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`. Qwen 3.8 27B and Flash map 0–39 / 40–79 / 80–100 to `low` / `medium` / `xhigh`; Qwen 3.8 Flash is the managed production version based on Flash-Next. GLM 5.3 and GLM 5.3 Flash require reasoning and map those bands to `low` / `high` / `max`. Gemini bands: 0–24 / 25–49 / 50–74 / 75–100. Gemini 3.8 Flash and 3.7 Flash support only `low` / `medium` / `high`, so the first two bands clamp to `low`; `-1` keeps their 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-6 Astra maps 0–39 / 40–59 / 60–79 / 80–99 / 100 to `low` / `medium` / `high` / `xhigh` / `max`. GPT-5.6 maps `100` to `max`; 80–99 remains `xhigh`. Qwen 3.8 27B and Flash map 0–39 / 40–79 / 80–100 to `low` / `medium` / `xhigh`; Qwen 3.8 Flash is the managed production version based on Flash-Next. GLM 5.3 and GLM 5.3 Flash require reasoning and map those bands to `low` / `high` / `max`. Gemini bands: 0–24 / 25–49 / 50–74 / 75–100. Gemini 3.8 Flash and 3.7 Flash support only `low` / `medium` / `high`, so the first two bands clamp to `low`; `-1` keeps their native `medium` default. DeepSeek `↑` = thinking on; `off` = thinking disabled. MiniMax `off`/`adaptive` = `thinking.disabled` / `thinking.type=adaptive`. Gemini 2.5 maps 0–100 to `thinkingBudget`. Anthropic: adaptive + `output_config.effort` on Claude 5 / Fable / Opus 4.6+ / Sonnet 4.6+; Sonnet 4.5 / Haiku 4.5 use `thinking.type=enabled` + `budget_tokens`. Grok 4.6 maps 0–39 / 40–59 / 60–79 / 80–100 to `low` / `medium` / `high` / `xhigh`; without effort it uses native `high`. `-1` = adaptive/dynamic when available, else no-op. Levels clamp per model. Former `*think()` methods are removed — use `.effort(n).<model>()`. Kimi: `kimiK25()` / `kimiK26()`. Grok 4.20: `.grok420()` non-reasoning; `.effort(20+|-1).grok420()` selects reasoning.
|
|
147
147
|
|
|
148
148
|
## Available Model Shorthands
|
|
149
149
|
|
|
@@ -151,9 +151,9 @@ ModelMix.new({ config: { effort: 80 } })
|
|
|
151
151
|
|
|
152
152
|
Use `ModerationMix.new().openai()` with `.raw()` to classify text and images through OpenAI's Moderations endpoint. Read the results from `raw.moderation`. `ModerationMix` accepts moderation providers as ordered fallbacks, rejects generative providers, and does not generate text or support streaming.
|
|
153
153
|
|
|
154
|
-
`gpt56sol()` `gpt56terra()` `gpt56luna()` `gpt55()` `gpt55pro()` `gpt54()` `gpt54mini()` `gpt54nano()` `gpt54pro()` `gpt53codex()` `gpt53chat()` `gpt52()` `gpt51()` `gpt5()` `gpt5mini()` `gpt5nano()` `gptRealtime()` `gptRealtimeMini()` `gptOss()`
|
|
154
|
+
`gpt6astra()` `gpt56sol()` `gpt56terra()` `gpt56luna()` `gpt55()` `gpt55pro()` `gpt54()` `gpt54mini()` `gpt54nano()` `gpt54pro()` `gpt53codex()` `gpt53chat()` `gpt52()` `gpt51()` `gpt5()` `gpt5mini()` `gpt5nano()` `gptRealtime()` `gptRealtimeMini()` `gptOss()`
|
|
155
155
|
|
|
156
|
-
Every textual GPT-5 shortcut registers only the official OpenAI model by default. Pass `mix: { openrouter: true }` to `ModelMix.new()` or to an individual shortcut to append its `openai/*` OpenRouter route as a fallback. `gpt53chat()` uses `gpt-5.3-chat-latest` officially and `openai/gpt-5.3-chat` through OpenRouter. Both API keys are required when that fallback is enabled. Realtime shortcuts remain official-only.
|
|
156
|
+
Every textual GPT-5 and GPT-6 shortcut registers only the official OpenAI model by default. Pass `mix: { openrouter: true }` to `ModelMix.new()` or to an individual shortcut to append its `openai/*` OpenRouter route as a fallback. `gpt53chat()` uses `gpt-5.3-chat-latest` officially and `openai/gpt-5.3-chat` through OpenRouter. Both API keys are required when that fallback is enabled. Realtime shortcuts remain official-only.
|
|
157
157
|
|
|
158
158
|
### Anthropic
|
|
159
159
|
`fable51()` `fable50()` `opus50()` `opus48()` `opus47()` `opus46()` `sonnet5()` `sonnet46()` `sonnet45()` `haiku45()`
|
|
@@ -183,14 +183,23 @@ Use `.effort(n)` (or `config.effort`) to enable Anthropic thinking — e.g. `.ef
|
|
|
183
183
|
### MiniMax
|
|
184
184
|
`minimaxM27()` `minimaxM3()`
|
|
185
185
|
|
|
186
|
+
### DeepSeek
|
|
187
|
+
`deepseekV41Flash({ mix: { deepseek: true, openrouter: false } })` uses the native API at `https://api.deepseek.com/chat/completions` with model `deepseek-flash` (currently V4.1 Flash). Requires `DEEPSEEK_API_KEY`; `MixDeepSeek` supports explicit `.attach()` calls. Unified effort and native cache usage are supported, and assistant reasoning is preserved for tool continuations. Cost estimates use peak rates per 1M tokens: $0.30 input, $0.006 cached input, $1.20 output; off-peak charges are half. Enabling all three providers orders them DeepSeek → Fireworks → OpenRouter.
|
|
188
|
+
|
|
186
189
|
### Fireworks
|
|
187
190
|
`museGlimmer30b()` `gptOss()` `qwen36plus()` (private/on-demand only) `qwen37plus()` `qwen38max()` `deepseekV4Flash()` `deepseekV4Pro()` `kimiK26()` `kimiK27Code()` `kimiK3()` `minimaxM27()` `minimaxM3()` `GLM52()`
|
|
188
191
|
|
|
192
|
+
`deepseekV41Flash({ mix: { fireworks: true, openrouter: false } })` selects `accounts/fireworks/models/deepseek-v4p1-flash` and requires `FIREWORKS_API_KEY`. Per 1M tokens: $0.22 input, $0.007 cached input, $0.66 output. Set both providers to `true` for Fireworks followed by OpenRouter fallback; no arguments selects OpenRouter.
|
|
193
|
+
|
|
189
194
|
### Cerebras
|
|
190
195
|
`GLM46()`
|
|
191
196
|
|
|
192
197
|
### OpenRouter
|
|
193
|
-
`
|
|
198
|
+
`deepseekPro()` uses `deepseek/deepseek-v4-pro-0813` through OpenRouter. Requires `OPENROUTER_API_KEY`; supports the DeepSeek effort mapping and `chain('deepseekPro@100')`. Base cost estimates per 1M tokens: $0.5808 input, $0.05808 cached input, $1.7424 output. Actual rates may change, including provider and time-based pricing.
|
|
199
|
+
|
|
200
|
+
`museGlimmer30b()` `museSpark12()` `museSpark12c()` `museSpark13()` `museSpark13c()` `gptOss()` `qwen35397b()` `qwen36plus()` `qwen37plus()` `qwen3827b()` `qwen38flash()` `hermes470b()` `hermes4405b()` `qwen38max()` `kimiK27Code()` `kimiK3()` `minimaxM27()` `minimaxM3()` `GLM45()` `GLM52()` `GLM53()` `GLM53Flash()` `deepseekV41Flash()`
|
|
201
|
+
|
|
202
|
+
`deepseekV41Flash()` selects `deepseek/deepseek-v4.1-flash` (text and image input) and requires `OPENROUTER_API_KEY`. It supports the DeepSeek effort mapping above, including `chain('deepseekV41Flash@100')`. Base cost estimates per 1M tokens: $0.15 input, $0.015 cached input, $0.60 output; actual OpenRouter pricing varies by provider and time.
|
|
194
203
|
|
|
195
204
|
Muse Spark: the `c` suffix selects Contributor, where prompts and outputs may be used to improve Meta products. Without `c`, the standard tier is selected. Use `museSpark12c()` for the former `museSpark12()` Contributor behavior.
|
|
196
205
|
|
|
@@ -651,7 +660,7 @@ const model = ModelMix.new({
|
|
|
651
660
|
|
|
652
661
|
## Available Provider Classes
|
|
653
662
|
|
|
654
|
-
`ModerationMix` `MixModeration` `MixOpenAI` `MixOpenAIResponses` `MixOpenAIModeration` `MixAnthropic` `MixGoogle` `MixPerplexity` `MixGroq` `MixTogether` `MixGrok` `MixOpenRouter` `MixOllama` `MixLMStudio` `MixCustom` `MixCerebras` `MixFireworks` `MixKimi` `MixMiniMax` `MixLambda`
|
|
663
|
+
`ModerationMix` `MixModeration` `MixOpenAI` `MixOpenAIResponses` `MixOpenAIModeration` `MixAnthropic` `MixGoogle` `MixPerplexity` `MixGroq` `MixTogether` `MixGrok` `MixOpenRouter` `MixOllama` `MixLMStudio` `MixCustom` `MixCerebras` `MixFireworks` `MixKimi` `MixMiniMax` `MixDeepSeek` `MixLambda`
|
|
655
664
|
|
|
656
665
|
## Troubleshooting
|
|
657
666
|
|