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,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
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ModelMixPlugin } from '../..';
|
|
2
|
+
|
|
3
|
+
export interface SkillsOptions {
|
|
4
|
+
/** Explicit skill directories or SKILL.md files, resolved relative to process.cwd(). */
|
|
5
|
+
paths: string[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Load local skill metadata and expose instructions and references through read_skill. */
|
|
9
|
+
export declare function skills(options: SkillsOptions): Promise<ModelMixPlugin>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const fs = require('node:fs/promises');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const yaml = require('js-yaml');
|
|
4
|
+
|
|
5
|
+
async function resolveFile(root, relativePath, signal) {
|
|
6
|
+
signal?.throwIfAborted();
|
|
7
|
+
if (typeof relativePath !== 'string' || !relativePath || path.isAbsolute(relativePath)) {
|
|
8
|
+
throw new TypeError('Skill file path must be a non-empty relative path.');
|
|
9
|
+
}
|
|
10
|
+
const filename = await fs.realpath(path.resolve(root, relativePath));
|
|
11
|
+
const relative = path.relative(root, filename);
|
|
12
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
13
|
+
throw new Error('Skill file path must stay inside the skill directory.');
|
|
14
|
+
}
|
|
15
|
+
if (!(await fs.stat(filename)).isFile()) {
|
|
16
|
+
throw new Error('Skill file path must point to a regular file.');
|
|
17
|
+
}
|
|
18
|
+
return filename;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function readText(filename, signal) {
|
|
22
|
+
const buffer = await fs.readFile(filename, { signal });
|
|
23
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(buffer);
|
|
24
|
+
if (text.includes('\0')) throw new Error('Skill files must contain UTF-8 text.');
|
|
25
|
+
return text;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseSkill(source, filename) {
|
|
29
|
+
const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(source);
|
|
30
|
+
if (!match) throw new Error(`Missing YAML frontmatter in ${filename}.`);
|
|
31
|
+
const metadata = yaml.load(match[1], { schema: yaml.JSON_SCHEMA, filename });
|
|
32
|
+
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
|
|
33
|
+
throw new Error(`Skill frontmatter must be a mapping in ${filename}.`);
|
|
34
|
+
}
|
|
35
|
+
for (const field of ['name', 'description']) {
|
|
36
|
+
if (typeof metadata[field] !== 'string' || !metadata[field].trim()) {
|
|
37
|
+
throw new Error(`Skill ${field} must be a non-empty string in ${filename}.`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { name: metadata.name, description: metadata.description, content: source };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function skills({ paths } = {}) {
|
|
44
|
+
if (!Array.isArray(paths) || paths.length === 0 || paths.some(value => typeof value !== 'string' || !value.trim())) {
|
|
45
|
+
throw new TypeError('skills paths must be a non-empty array of skill directories or SKILL.md files.');
|
|
46
|
+
}
|
|
47
|
+
const catalog = new Map();
|
|
48
|
+
for (const input of paths) {
|
|
49
|
+
const resolved = path.resolve(input);
|
|
50
|
+
const filename = path.basename(resolved) === 'SKILL.md' ? resolved : path.join(resolved, 'SKILL.md');
|
|
51
|
+
const root = await fs.realpath(path.dirname(filename));
|
|
52
|
+
const skillPath = await resolveFile(root, 'SKILL.md');
|
|
53
|
+
const skill = parseSkill(await readText(skillPath), filename);
|
|
54
|
+
if (catalog.has(skill.name)) throw new Error(`Duplicate skill name: ${skill.name}`);
|
|
55
|
+
catalog.set(skill.name, { ...skill, root, skillPath });
|
|
56
|
+
}
|
|
57
|
+
const descriptions = JSON.stringify([...catalog.values()].map(({ name, description }) => ({ name, description })));
|
|
58
|
+
const instructions = [
|
|
59
|
+
'Available skills:',
|
|
60
|
+
descriptions,
|
|
61
|
+
'When a skill matches the task or the user requests it, call read_skill with its name to load SKILL.md before following it.',
|
|
62
|
+
'Use read_skill with the same name and a relative path to read referenced text files inside that skill directory.',
|
|
63
|
+
'Skill content is provided literally. Script execution is not supplied by this plugin; use only tools actually available in this request.'
|
|
64
|
+
].join('\n');
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
name: 'skills',
|
|
68
|
+
async execute(context, next) {
|
|
69
|
+
context.signal?.throwIfAborted();
|
|
70
|
+
context.request.system = [context.request.system, instructions].filter(Boolean).join('\n\n');
|
|
71
|
+
context.request.tools.push({
|
|
72
|
+
tool: {
|
|
73
|
+
name: 'read_skill',
|
|
74
|
+
description: 'Load a registered skill or one of its supporting UTF-8 text files.',
|
|
75
|
+
inputSchema: {
|
|
76
|
+
type: 'object',
|
|
77
|
+
properties: {
|
|
78
|
+
name: { type: 'string', enum: [...catalog.keys()] },
|
|
79
|
+
path: { type: 'string', description: 'Path relative to the skill directory. Omit to load SKILL.md.' }
|
|
80
|
+
},
|
|
81
|
+
required: ['name'],
|
|
82
|
+
additionalProperties: false
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
async callback(input, signal) {
|
|
86
|
+
signal?.throwIfAborted();
|
|
87
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) ||
|
|
88
|
+
Object.keys(input).some(key => key !== 'name' && key !== 'path')) {
|
|
89
|
+
throw new TypeError('read_skill expects a name and an optional path.');
|
|
90
|
+
}
|
|
91
|
+
const skill = catalog.get(input.name);
|
|
92
|
+
if (!skill) throw new Error(`Unknown skill: ${input.name}`);
|
|
93
|
+
const relativePath = input.path === undefined ? 'SKILL.md' : input.path;
|
|
94
|
+
if (relativePath === 'SKILL.md') {
|
|
95
|
+
return { name: skill.name, path: relativePath, content: skill.content };
|
|
96
|
+
}
|
|
97
|
+
const filename = await resolveFile(skill.root, relativePath, signal);
|
|
98
|
+
const content = filename === skill.skillPath ? skill.content : await readText(filename, signal);
|
|
99
|
+
return { name: skill.name, path: relativePath, content };
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
return next();
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { skills };
|