modelmix 5.0.2 → 5.0.4

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.
Files changed (39) hide show
  1. package/README.md +121 -7
  2. package/RLM_PLUGIN_SPEC.md +465 -0
  3. package/demo/gemini.js +3 -4
  4. package/demo/moderation.js +17 -0
  5. package/demo/short.js +1 -1
  6. package/effort.js +2 -0
  7. package/index.d.ts +107 -1
  8. package/index.js +443 -46
  9. package/package.json +4 -2
  10. package/plugins/rlm/index.d.ts +194 -0
  11. package/plugins/rlm/index.js +25 -0
  12. package/plugins/rlm/lib/budget.js +153 -0
  13. package/plugins/rlm/lib/isolated-vm-sandbox.js +90 -0
  14. package/plugins/rlm/lib/markdown.js +156 -0
  15. package/plugins/rlm/lib/planner-prompt.js +137 -0
  16. package/plugins/rlm/lib/plugin.js +203 -0
  17. package/plugins/rlm/lib/runtime.js +146 -0
  18. package/plugins/rlm/lib/variable-descriptors.js +228 -0
  19. package/plugins/rlm/lib/worker-catalog.js +70 -0
  20. package/plugins/rlm/package.json +32 -0
  21. package/plugins/rlm/prompts/partials/processing-rules.md +8 -0
  22. package/plugins/rlm/prompts/planner.md +53 -0
  23. package/plugins/rlm/test/budget.test.js +86 -0
  24. package/plugins/rlm/test/fixtures/book.md +24 -0
  25. package/plugins/rlm/test/isolated-vm-sandbox.test.js +114 -0
  26. package/plugins/rlm/test/markdown.test.js +64 -0
  27. package/plugins/rlm/test/planner-template.test.js +140 -0
  28. package/plugins/rlm/test/plugin-contract.test.js +182 -0
  29. package/plugins/rlm/test/rlm-e2e.test.js +338 -0
  30. package/plugins/rlm/test/variable-descriptors.test.js +170 -0
  31. package/plugins/rlm/test/worker-catalog.test.js +104 -0
  32. package/pnpm-workspace.yaml +6 -0
  33. package/skills/modelmix/SKILL.md +26 -4
  34. package/test/effort.test.js +14 -1
  35. package/test/live.mcp.js +6 -6
  36. package/test/live.test.js +2 -2
  37. package/test/moderation.test.js +135 -0
  38. package/test/plugins.test.js +356 -0
  39. package/test/tokens.test.js +37 -5
@@ -0,0 +1,356 @@
1
+ const { expect } = require('chai');
2
+ const path = require('path');
3
+ const { MixCustom, ModelMix } = require('../index.js');
4
+
5
+ function createProvider(handler = async () => ({ message: 'provider', toolCalls: [] })) {
6
+ const provider = new MixCustom();
7
+ provider.create = handler;
8
+ return provider;
9
+ }
10
+
11
+ describe('ModelMix plugins', () => {
12
+ it('keeps registration instance-scoped and lets new instances inherit plugins without history', () => {
13
+ const plugin = { name: 'metrics', execute: (_context, next) => next() };
14
+ const parent = ModelMix.new().use(plugin).addText('parent history');
15
+ const sibling = ModelMix.new();
16
+ const child = parent.new();
17
+
18
+ expect(parent.plugins).to.deep.equal([plugin]);
19
+ expect(child.plugins).to.deep.equal([plugin]);
20
+ expect(child.plugins).to.not.equal(parent.plugins);
21
+ expect(child.messages).to.deep.equal([]);
22
+ expect(sibling.plugins).to.deep.equal([]);
23
+ });
24
+
25
+ it('runs middleware in registration order and allows request transforms', async () => {
26
+ let providerRequest;
27
+ const provider = createProvider(async request => {
28
+ providerRequest = request;
29
+ return { message: 'done', toolCalls: [] };
30
+ });
31
+ const events = [];
32
+ const model = ModelMix.new()
33
+ .attach('custom', provider)
34
+ .use({
35
+ name: 'outer',
36
+ async execute(context, next) {
37
+ events.push('outer:before');
38
+ context.request.system = 'plugin system';
39
+ context.request.messages[0].content[0].text = 'plugin message';
40
+ context.request.options.temperature = 0.25;
41
+ context.request.config.marker = 'plugin config';
42
+ const result = await next();
43
+ events.push('outer:after');
44
+ return { ...result, wrapped: true };
45
+ }
46
+ })
47
+ .use({
48
+ name: 'inner',
49
+ async execute(_context, next) {
50
+ events.push('inner:before');
51
+ const result = await next();
52
+ events.push('inner:after');
53
+ return result;
54
+ }
55
+ })
56
+ .addText('original');
57
+
58
+ const result = await model.raw();
59
+
60
+ expect(events).to.deep.equal([
61
+ 'outer:before',
62
+ 'inner:before',
63
+ 'inner:after',
64
+ 'outer:after'
65
+ ]);
66
+ expect(providerRequest.options.messages[0].content[0].text).to.equal('plugin message');
67
+ expect(providerRequest.options.temperature).to.equal(0.25);
68
+ expect(providerRequest.config.system).to.equal('plugin system');
69
+ expect(providerRequest.config.marker).to.equal('plugin config');
70
+ expect(result).to.include({ message: 'done', wrapped: true });
71
+ expect(model.lastRaw).to.equal(result);
72
+ });
73
+
74
+ it('supports short-circuit results across non-streaming output modes', async () => {
75
+ let providerCalls = 0;
76
+ const createModel = () => ModelMix.new()
77
+ .attach('custom', createProvider(async () => {
78
+ providerCalls += 1;
79
+ return { message: 'provider', toolCalls: [] };
80
+ }))
81
+ .use({
82
+ name: 'short-circuit',
83
+ async execute() {
84
+ return { message: '```json\n{"answer":"plugin"}\n```', source: 'plugin' };
85
+ }
86
+ })
87
+ .addText('ignored');
88
+
89
+ expect(await createModel().message()).to.include('"answer":"plugin"');
90
+ expect(await createModel().block()).to.equal('{"answer":"plugin"}');
91
+ expect(await createModel().json()).to.deep.equal({ answer: 'plugin' });
92
+ expect(await createModel().raw()).to.include({ source: 'plugin' });
93
+ expect(providerCalls).to.equal(0);
94
+ });
95
+
96
+ it('wraps a complete tool-call execution without rerunning middleware', async () => {
97
+ const providerMessages = [];
98
+ let providerCalls = 0;
99
+ let middlewareCalls = 0;
100
+ const provider = createProvider(async ({ options }) => {
101
+ providerCalls += 1;
102
+ providerMessages.push(options.messages);
103
+ if (providerCalls === 1) {
104
+ return {
105
+ message: '',
106
+ toolCalls: [{ id: 'tool-1', name: 'noop', input: {} }]
107
+ };
108
+ }
109
+ return { message: 'tool complete', toolCalls: [] };
110
+ });
111
+ const model = ModelMix.new()
112
+ .attach('custom', provider)
113
+ .addTool({
114
+ name: 'noop',
115
+ description: 'Return a fixed result.',
116
+ inputSchema: { type: 'object' }
117
+ }, async () => 'ok')
118
+ .use({
119
+ name: 'transform',
120
+ async execute(context, next) {
121
+ middlewareCalls += 1;
122
+ context.request.messages[0].content[0].text = 'transformed prompt';
123
+ return next();
124
+ }
125
+ })
126
+ .addText('original prompt');
127
+
128
+ expect(await model.message()).to.equal('tool complete');
129
+ expect(middlewareCalls).to.equal(1);
130
+ expect(providerCalls).to.equal(2);
131
+ expect(providerMessages[0][0].content[0].text).to.equal('transformed prompt');
132
+ expect(providerMessages[1][0].content[0].text).to.equal('transformed prompt');
133
+ expect(providerMessages[1].some(message => message.role === 'tool')).to.equal(true);
134
+ });
135
+
136
+ for (const [policyName, policy, expectedPlugins] of [
137
+ ['inherit', 'inherit', ['parent', 'alpha', 'beta']],
138
+ ['none', 'none', []],
139
+ ['include', { include: ['beta'] }, ['beta']],
140
+ ['exclude', { exclude: ['parent', 'alpha'] }, ['beta']]
141
+ ]) {
142
+ it(`applies the ${policyName} child plugin policy and execution metadata`, async () => {
143
+ const events = [];
144
+ let childProviderRequest;
145
+ const provider = createProvider(async request => {
146
+ childProviderRequest = request;
147
+ return { message: 'child result', toolCalls: [] };
148
+ });
149
+ const parentPlugin = {
150
+ name: 'parent',
151
+ async execute(context, next) {
152
+ events.push({ name: 'parent', ...context.execution });
153
+ if (context.execution.depth > 0) return next();
154
+ return context.invoke({
155
+ system: 'child system',
156
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'child prompt' }] }],
157
+ plugins: policy
158
+ });
159
+ }
160
+ };
161
+ const recorder = name => ({
162
+ name,
163
+ async execute(context, next) {
164
+ events.push({ name, ...context.execution });
165
+ return next();
166
+ }
167
+ });
168
+ const model = ModelMix.new({ config: { max_history: -1 } })
169
+ .attach('custom', provider)
170
+ .use(parentPlugin)
171
+ .use(recorder('alpha'))
172
+ .use(recorder('beta'))
173
+ .addText('parent prompt');
174
+
175
+ const result = await model.raw();
176
+ const childEvents = events.filter(event => event.depth === 1);
177
+
178
+ expect(result.message).to.equal('child result');
179
+ expect(result.execution).to.deep.include({
180
+ parentExecutionId: events[0].executionId,
181
+ depth: 1
182
+ });
183
+ expect(childEvents.map(event => event.name)).to.deep.equal(expectedPlugins);
184
+ expect(events[0].depth).to.equal(0);
185
+ expect(events[0].parentExecutionId).to.equal(null);
186
+ for (const childEvent of childEvents) {
187
+ expect(childEvent.executionId).to.equal(childEvents[0].executionId);
188
+ expect(childEvent.parentExecutionId).to.equal(events[0].executionId);
189
+ }
190
+ expect(childEvents[0]?.executionId).to.not.equal(events[0].executionId);
191
+ expect(childProviderRequest.options.messages).to.deep.equal([
192
+ { role: 'user', content: [{ type: 'text', text: 'child prompt' }] }
193
+ ]);
194
+ expect(childProviderRequest.config.system).to.equal('child system');
195
+ });
196
+ }
197
+
198
+ it('can invoke a child through another ModelMix worker chain', async () => {
199
+ let parentProviderCalls = 0;
200
+ const parentProvider = createProvider(async () => {
201
+ parentProviderCalls += 1;
202
+ return { message: 'parent', toolCalls: [] };
203
+ });
204
+ let workerRequest;
205
+ const worker = ModelMix.new({
206
+ options: { temperature: 0.25 },
207
+ config: { workerPolicy: 'preserved' }
208
+ }).attach('worker', createProvider(async request => {
209
+ workerRequest = request;
210
+ return {
211
+ message: 'worker result',
212
+ toolCalls: []
213
+ };
214
+ }));
215
+ const model = ModelMix.new()
216
+ .attach('parent', parentProvider)
217
+ .use({
218
+ name: 'worker-router',
219
+ execute(context) {
220
+ return context.invoke({
221
+ model: worker,
222
+ messages: [{ role: 'user', content: 'worker prompt' }],
223
+ plugins: 'none'
224
+ });
225
+ }
226
+ })
227
+ .addText('parent prompt');
228
+
229
+ expect(await model.message()).to.equal('worker result');
230
+ expect(parentProviderCalls).to.equal(0);
231
+ expect(workerRequest.options.temperature).to.equal(0.25);
232
+ expect(workerRequest.config.workerPolicy).to.equal('preserved');
233
+ });
234
+
235
+ it('renders child system files with ModelMix assign data and relative includes', async () => {
236
+ let providerRequest;
237
+ const model = ModelMix.new()
238
+ .attach('custom', createProvider(async request => {
239
+ providerRequest = request;
240
+ return { message: 'rendered', toolCalls: [] };
241
+ }))
242
+ .use({
243
+ name: 'template-child',
244
+ execute(context) {
245
+ return context.invoke({
246
+ systemFile: path.join(__dirname, 'fixtures/system-template.txt'),
247
+ assign: {
248
+ role: '<%- mustRemainData %>',
249
+ language: 'English'
250
+ },
251
+ messages: [{ role: 'user', content: 'child task' }],
252
+ plugins: 'none'
253
+ });
254
+ }
255
+ })
256
+ .addText('parent task');
257
+
258
+ expect(await model.message()).to.equal('rendered');
259
+ expect(providerRequest.config.system.trimEnd()).to.equal([
260
+ 'You are a <%- mustRemainData %>.',
261
+ 'Always respond in English.'
262
+ ].join('\n'));
263
+ });
264
+
265
+ it('rejects duplicate names, invalid child history, and repeated next calls', async () => {
266
+ const duplicate = { name: 'duplicate', execute: (_context, next) => next() };
267
+ const model = ModelMix.new().use(duplicate);
268
+ expect(() => model.use(duplicate)).to.throw('already registered');
269
+
270
+ const historyModel = ModelMix.new()
271
+ .attach('custom', createProvider())
272
+ .use({
273
+ name: 'history',
274
+ execute(context) {
275
+ return context.invoke({ messages: [], history: true });
276
+ }
277
+ })
278
+ .addText('parent');
279
+ let historyError;
280
+ try {
281
+ await historyModel.raw();
282
+ } catch (error) {
283
+ historyError = error;
284
+ }
285
+ expect(historyError).to.be.instanceOf(TypeError);
286
+ expect(historyError.message).to.include('history: false');
287
+
288
+ const nextModel = ModelMix.new()
289
+ .attach('custom', createProvider())
290
+ .use({
291
+ name: 'twice',
292
+ async execute(_context, next) {
293
+ await next();
294
+ return next();
295
+ }
296
+ })
297
+ .addText('parent');
298
+ let nextError;
299
+ try {
300
+ await nextModel.raw();
301
+ } catch (error) {
302
+ nextError = error;
303
+ }
304
+ expect(nextError).to.be.instanceOf(Error);
305
+ expect(nextError.message).to.include('multiple times');
306
+
307
+ const conflictingSystemModel = ModelMix.new()
308
+ .attach('custom', createProvider())
309
+ .use({
310
+ name: 'conflicting-system',
311
+ execute(context) {
312
+ return context.invoke({
313
+ system: 'inline',
314
+ systemFile: 'system.md',
315
+ messages: [],
316
+ plugins: 'none'
317
+ });
318
+ }
319
+ })
320
+ .addText('parent');
321
+ let conflictingSystemError;
322
+ try {
323
+ await conflictingSystemModel.raw();
324
+ } catch (error) {
325
+ conflictingSystemError = error;
326
+ }
327
+ expect(conflictingSystemError).to.be.instanceOf(TypeError);
328
+ expect(conflictingSystemError.message).to.include('only one of system or systemFile');
329
+ });
330
+
331
+ it('does not fall through to providers when a plugin fails', async () => {
332
+ let providerCalls = 0;
333
+ const model = ModelMix.new()
334
+ .attach('custom', createProvider(async () => {
335
+ providerCalls += 1;
336
+ return { message: 'provider', toolCalls: [] };
337
+ }))
338
+ .use({
339
+ name: 'failure',
340
+ async execute() {
341
+ throw new Error('plugin failure');
342
+ }
343
+ })
344
+ .addText('prompt');
345
+
346
+ let failure;
347
+ try {
348
+ await model.raw();
349
+ } catch (error) {
350
+ failure = error;
351
+ }
352
+ expect(failure).to.be.instanceOf(Error);
353
+ expect(failure.message).to.equal('plugin failure');
354
+ expect(providerCalls).to.equal(0);
355
+ });
356
+ });
@@ -53,7 +53,8 @@ describe('Token Usage Tracking', () => {
53
53
  usageMetadata: {
54
54
  promptTokenCount: 70,
55
55
  candidatesTokenCount: 10,
56
- totalTokenCount: 80,
56
+ thoughtsTokenCount: 5,
57
+ totalTokenCount: 85,
57
58
  cachedContentTokenCount: 35
58
59
  }
59
60
  });
@@ -88,7 +89,8 @@ describe('Token Usage Tracking', () => {
88
89
  expect(googleTokens).to.include({
89
90
  input: 70,
90
91
  output: 10,
91
- total: 80,
92
+ thinking: 5,
93
+ total: 85,
92
94
  cached: 35,
93
95
  cacheWrite: 0,
94
96
  uncachedInput: 35,
@@ -500,21 +502,51 @@ describe('Token Usage Tracking', () => {
500
502
 
501
503
  it('should register Gemini Flash shortcuts with Google provider', function () {
502
504
  const model = ModelMix.new()
505
+ .gemini37flash()
503
506
  .gemini36flash()
504
507
  .gemini35flash()
505
508
  .gemini35flashLite();
506
509
 
507
510
  expect(model.models.map(({ key }) => key)).to.deep.equal([
511
+ 'gemini-3.7-flash',
508
512
  'gemini-3.6-flash',
509
513
  'gemini-3.5-flash',
510
514
  'gemini-3.5-flash-lite'
511
515
  ]);
512
516
  expect(model.models.every(({ provider }) => provider instanceof MixGoogle)).to.equal(true);
513
- expect(ModelMix.calculateCost('gemini-3.6-flash', { input: 1_000_000, output: 1_000_000 })).to.equal(9);
517
+ expect(ModelMix.calculateCost('gemini-3.7-flash', { input: 1_000_000, output: 1_000_000 })).to.equal(4.5);
518
+ expect(ModelMix.calculateCost('gemini-3.6-flash', { input: 1_000_000, output: 1_000_000 })).to.equal(4.5);
514
519
  expect(ModelMix.calculateCost('gemini-3.5-flash', { input: 1_000_000, output: 1_000_000 })).to.equal(5.25);
515
520
  expect(ModelMix.calculateCost('gemini-3.5-flash-lite', { input: 1_000_000, output: 1_000_000 })).to.equal(2.8);
516
521
  });
517
522
 
523
+ it('should calculate Gemini 3.7 Flash cache reads at the introductory rate', function () {
524
+ expect(ModelMix.calculateCostBreakdown('gemini-3.7-flash', {
525
+ input: 1_000_000,
526
+ output: 1_000_000,
527
+ thinking: 500_000,
528
+ cached: 1_000_000
529
+ })).to.deep.equal({
530
+ uncachedInput: 0,
531
+ cachedInput: 0.075,
532
+ cacheWrite: 0,
533
+ cacheWrite5m: 0,
534
+ cacheWrite1h: 0,
535
+ output: 5.625,
536
+ total: 5.7
537
+ });
538
+ });
539
+
540
+ it('should forward options and config through gemini37flash()', function () {
541
+ const options = { thinkingLevel: 'high' };
542
+ const config = { max_history: 3 };
543
+ const model = ModelMix.new().gemini37flash({ options, config });
544
+
545
+ expect(model.models[0].provider).to.be.instanceOf(MixGoogle);
546
+ expect(model.models[0].provider.options).to.deep.equal(options);
547
+ expect(model.models[0].provider.config).to.include(config);
548
+ });
549
+
518
550
  it('should register MiMo shortcuts with native and OpenRouter providers', function () {
519
551
  const originalMimoApiKey = process.env.MIMO_API_KEY;
520
552
  const originalOpenRouterApiKey = process.env.OPENROUTER_API_KEY;
@@ -620,7 +652,7 @@ describe('Token Usage Tracking', () => {
620
652
  this.timeout(30000);
621
653
 
622
654
  const model = ModelMix.new()
623
- .gemini3flash()
655
+ .gemini37flash()
624
656
  .addText('Say hi');
625
657
 
626
658
  const result = await model.raw();
@@ -683,7 +715,7 @@ describe('Token Usage Tracking', () => {
683
715
  const providers = [
684
716
  { name: 'OpenAI', create: (m) => m.gpt56luna() },
685
717
  { name: 'Anthropic', create: (m) => m.haiku45() },
686
- { name: 'Google', create: (m) => m.gemini3flash() }
718
+ { name: 'Google', create: (m) => m.gemini37flash() }
687
719
  ];
688
720
 
689
721
  for (const provider of providers) {