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.
- package/README.md +121 -7
- package/RLM_PLUGIN_SPEC.md +465 -0
- package/demo/gemini.js +3 -4
- package/demo/moderation.js +17 -0
- package/demo/short.js +1 -1
- package/effort.js +2 -0
- package/index.d.ts +107 -1
- package/index.js +443 -46
- package/package.json +4 -2
- package/plugins/rlm/index.d.ts +194 -0
- package/plugins/rlm/index.js +25 -0
- package/plugins/rlm/lib/budget.js +153 -0
- package/plugins/rlm/lib/isolated-vm-sandbox.js +90 -0
- package/plugins/rlm/lib/markdown.js +156 -0
- package/plugins/rlm/lib/planner-prompt.js +137 -0
- package/plugins/rlm/lib/plugin.js +203 -0
- package/plugins/rlm/lib/runtime.js +146 -0
- package/plugins/rlm/lib/variable-descriptors.js +228 -0
- package/plugins/rlm/lib/worker-catalog.js +70 -0
- package/plugins/rlm/package.json +32 -0
- package/plugins/rlm/prompts/partials/processing-rules.md +8 -0
- package/plugins/rlm/prompts/planner.md +53 -0
- package/plugins/rlm/test/budget.test.js +86 -0
- package/plugins/rlm/test/fixtures/book.md +24 -0
- package/plugins/rlm/test/isolated-vm-sandbox.test.js +114 -0
- package/plugins/rlm/test/markdown.test.js +64 -0
- package/plugins/rlm/test/planner-template.test.js +140 -0
- package/plugins/rlm/test/plugin-contract.test.js +182 -0
- package/plugins/rlm/test/rlm-e2e.test.js +338 -0
- package/plugins/rlm/test/variable-descriptors.test.js +170 -0
- package/plugins/rlm/test/worker-catalog.test.js +104 -0
- package/pnpm-workspace.yaml +6 -0
- package/skills/modelmix/SKILL.md +26 -4
- package/test/effort.test.js +14 -1
- package/test/live.mcp.js +6 -6
- package/test/live.test.js +2 -2
- package/test/moderation.test.js +135 -0
- package/test/plugins.test.js +356 -0
- package/test/tokens.test.js +37 -5
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
const { expect } = require('chai');
|
|
2
|
+
const { describeVariables, plannerTemplateData } = require('..');
|
|
3
|
+
|
|
4
|
+
describe('RLM variable metadata', () => {
|
|
5
|
+
const workerManifest = {
|
|
6
|
+
fast: {
|
|
7
|
+
intelligence: 1,
|
|
8
|
+
cost: 1,
|
|
9
|
+
speed: 5,
|
|
10
|
+
description: 'Fast transformations'
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
const runtimeLimits = {
|
|
14
|
+
maxCalls: 20,
|
|
15
|
+
maxOutputBytes: 1024 * 1024,
|
|
16
|
+
maxGeneratedTokens: 10000,
|
|
17
|
+
maxWallTimeMs: 30000
|
|
18
|
+
};
|
|
19
|
+
const book = {
|
|
20
|
+
title: 'A hidden title that must not reach the planner',
|
|
21
|
+
chapters: [
|
|
22
|
+
{
|
|
23
|
+
heading: 'First hidden chapter',
|
|
24
|
+
content: 'First secret paragraph.\n\nSecond secret paragraph.',
|
|
25
|
+
tags: ['opening', 'setup']
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
heading: 'Second hidden chapter',
|
|
29
|
+
content: 'Árbol and emoji 😀.\n\nAnother concealed paragraph.',
|
|
30
|
+
tags: ['middle']
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
it('describes strings, objects, and arrays without exposing their content', () => {
|
|
36
|
+
const manifest = describeVariables(book);
|
|
37
|
+
const title = manifest.descriptors.title;
|
|
38
|
+
const chapters = manifest.descriptors.chapters;
|
|
39
|
+
|
|
40
|
+
expect(manifest).to.include({
|
|
41
|
+
sizeBasis: 'serialized-json-utf8',
|
|
42
|
+
variables: 2,
|
|
43
|
+
estimatedBytes: Buffer.byteLength(JSON.stringify(book), 'utf8')
|
|
44
|
+
});
|
|
45
|
+
expect(title).to.include({
|
|
46
|
+
path: 'title',
|
|
47
|
+
type: 'string',
|
|
48
|
+
characters: Array.from(book.title).length,
|
|
49
|
+
utf16CodeUnits: book.title.length,
|
|
50
|
+
utf8Bytes: Buffer.byteLength(book.title, 'utf8'),
|
|
51
|
+
lines: 1,
|
|
52
|
+
paragraphs: 1
|
|
53
|
+
});
|
|
54
|
+
expect(chapters).to.include({
|
|
55
|
+
path: 'chapters',
|
|
56
|
+
type: 'array',
|
|
57
|
+
items: 2,
|
|
58
|
+
estimatedBytes: Buffer.byteLength(JSON.stringify(book.chapters), 'utf8')
|
|
59
|
+
});
|
|
60
|
+
expect(chapters.itemSize).to.deep.equal({
|
|
61
|
+
min: Math.min(...book.chapters.map(chapter => Buffer.byteLength(JSON.stringify(chapter), 'utf8'))),
|
|
62
|
+
max: Math.max(...book.chapters.map(chapter => Buffer.byteLength(JSON.stringify(chapter), 'utf8'))),
|
|
63
|
+
average: Number((book.chapters
|
|
64
|
+
.map(chapter => Buffer.byteLength(JSON.stringify(chapter), 'utf8'))
|
|
65
|
+
.reduce((sum, bytes) => sum + bytes, 0) / 2).toFixed(2)),
|
|
66
|
+
total: book.chapters
|
|
67
|
+
.map(chapter => Buffer.byteLength(JSON.stringify(chapter), 'utf8'))
|
|
68
|
+
.reduce((sum, bytes) => sum + bytes, 0)
|
|
69
|
+
});
|
|
70
|
+
expect(chapters.itemShape.types).to.deep.equal({ object: 2 });
|
|
71
|
+
expect(chapters.itemShape.properties.content).to.deep.include({
|
|
72
|
+
present: 2,
|
|
73
|
+
missing: 0,
|
|
74
|
+
types: { string: 2 }
|
|
75
|
+
});
|
|
76
|
+
expect(chapters.itemShape.properties.content.stringSize.paragraphs).to.deep.equal({
|
|
77
|
+
min: 2,
|
|
78
|
+
max: 2,
|
|
79
|
+
average: 2,
|
|
80
|
+
total: 4
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const serializedManifest = JSON.stringify(manifest);
|
|
84
|
+
expect(serializedManifest).to.not.include(book.title);
|
|
85
|
+
for (const chapter of book.chapters) {
|
|
86
|
+
expect(serializedManifest).to.not.include(chapter.heading);
|
|
87
|
+
expect(serializedManifest).to.not.include(chapter.content);
|
|
88
|
+
for (const tag of chapter.tags) expect(serializedManifest).to.not.include(tag);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('keeps exact aggregate sizes for large arrays without listing their items', () => {
|
|
93
|
+
const values = Array.from({ length: 100000 }, (_, index) => `row-${index}`);
|
|
94
|
+
const descriptor = describeVariables({ values }).descriptors.values;
|
|
95
|
+
|
|
96
|
+
expect(descriptor.items).to.equal(values.length);
|
|
97
|
+
expect(descriptor.estimatedBytes).to.equal(Buffer.byteLength(JSON.stringify(values), 'utf8'));
|
|
98
|
+
expect(descriptor).to.not.have.property('children');
|
|
99
|
+
expect(JSON.stringify(descriptor)).to.not.include('row-99999');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('rejects values that cannot safely enter the sandbox environment', () => {
|
|
103
|
+
const circular = {};
|
|
104
|
+
circular.self = circular;
|
|
105
|
+
|
|
106
|
+
expect(() => describeVariables({ circular })).to.throw('circular reference');
|
|
107
|
+
expect(() => describeVariables({ callback() {} })).to.throw('unsupported type function');
|
|
108
|
+
expect(() => describeVariables({ invalid: Infinity })).to.throw('finite numbers');
|
|
109
|
+
expect(() => describeVariables({ date: new Date() })).to.throw('plain objects');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('adds limits and actionable partition guidance to the planner prompt', () => {
|
|
113
|
+
const templateData = plannerTemplateData({
|
|
114
|
+
variables: book,
|
|
115
|
+
limits: {
|
|
116
|
+
...runtimeLimits,
|
|
117
|
+
maxQueryBytes: 32,
|
|
118
|
+
sandboxMemoryBytes: 64 * 1024 * 1024,
|
|
119
|
+
maxConcurrentQueries: 4
|
|
120
|
+
},
|
|
121
|
+
workerManifest
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(templateData.variableManifest).to.include('"items": 2');
|
|
125
|
+
expect(templateData.processingLimits).to.include('"maxQueryBytes": 32');
|
|
126
|
+
expect(templateData.processingLimits).to.include('"sandboxMemoryBytes": 67108864');
|
|
127
|
+
expect(templateData.processingLimits).to.include('"maxConcurrentQueries": 4');
|
|
128
|
+
expect(templateData.planningHints).to.include('"strategy": "split-oversized-items-semantically"');
|
|
129
|
+
expect(templateData.planningHints).to.include('"oversizedStringFields"');
|
|
130
|
+
expect(templateData.planningHints).to.include('"content"');
|
|
131
|
+
const serializedTemplateData = JSON.stringify(templateData);
|
|
132
|
+
expect(serializedTemplateData).to.not.include(book.title);
|
|
133
|
+
for (const chapter of book.chapters) {
|
|
134
|
+
expect(serializedTemplateData).to.not.include(chapter.heading);
|
|
135
|
+
expect(serializedTemplateData).to.not.include(chapter.content);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('distinguishes direct values from arrays that should be batched', () => {
|
|
140
|
+
const templateData = plannerTemplateData({
|
|
141
|
+
variables: {
|
|
142
|
+
instruction: 'short',
|
|
143
|
+
paragraphs: Array.from({ length: 20 }, () => 'small paragraph')
|
|
144
|
+
},
|
|
145
|
+
limits: {
|
|
146
|
+
...runtimeLimits,
|
|
147
|
+
maxQueryBytes: 80,
|
|
148
|
+
sandboxMemoryBytes: 1024 * 1024,
|
|
149
|
+
maxConcurrentQueries: 2
|
|
150
|
+
},
|
|
151
|
+
workerManifest
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(templateData.planningHints).to.include('"path": "instruction"');
|
|
155
|
+
expect(templateData.planningHints).to.include('"strategy": "direct"');
|
|
156
|
+
expect(templateData.planningHints).to.include('"path": "paragraphs"');
|
|
157
|
+
expect(templateData.planningHints).to.include('"strategy": "batch-array-items"');
|
|
158
|
+
expect(templateData.planningHints).to.match(/"suggestedMaxItemsPerQuery": [1-9]\d*/);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('requires explicit planning limits instead of silent defaults', () => {
|
|
162
|
+
expect(() => plannerTemplateData({ variables: {}, limits: {}, workerManifest }))
|
|
163
|
+
.to.throw('limits.maxQueryBytes');
|
|
164
|
+
expect(() => plannerTemplateData({
|
|
165
|
+
variables: {},
|
|
166
|
+
limits: { maxQueryBytes: 100, sandboxMemoryBytes: 1000 },
|
|
167
|
+
workerManifest
|
|
168
|
+
})).to.throw('limits.maxConcurrentQueries');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const { expect } = require('chai');
|
|
2
|
+
const { MixCustom, ModelMix } = require('../../../index.js');
|
|
3
|
+
const { createWorkerCatalog } = require('..');
|
|
4
|
+
|
|
5
|
+
function workerModel(secret = 'worker-secret') {
|
|
6
|
+
return ModelMix.new().attach('worker-model', new MixCustom({
|
|
7
|
+
config: { apiKey: secret, url: 'https://private-provider.invalid' }
|
|
8
|
+
}));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('RLM worker catalog', () => {
|
|
12
|
+
it('exposes decision metadata without models, providers, or credentials', () => {
|
|
13
|
+
const model = workerModel();
|
|
14
|
+
const catalog = createWorkerCatalog({
|
|
15
|
+
expert: {
|
|
16
|
+
model,
|
|
17
|
+
intelligence: 5,
|
|
18
|
+
cost: 4,
|
|
19
|
+
speed: 2,
|
|
20
|
+
description: 'Complex synthesis'
|
|
21
|
+
},
|
|
22
|
+
fast: {
|
|
23
|
+
model: workerModel('another-secret'),
|
|
24
|
+
intelligence: 1,
|
|
25
|
+
cost: 1,
|
|
26
|
+
speed: 5,
|
|
27
|
+
description: 'Extraction and classification'
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(catalog.get('expert')).to.equal(model);
|
|
32
|
+
expect(catalog.manifest).to.deep.equal({
|
|
33
|
+
expert: {
|
|
34
|
+
intelligence: 5,
|
|
35
|
+
cost: 4,
|
|
36
|
+
speed: 2,
|
|
37
|
+
description: 'Complex synthesis'
|
|
38
|
+
},
|
|
39
|
+
fast: {
|
|
40
|
+
intelligence: 1,
|
|
41
|
+
cost: 1,
|
|
42
|
+
speed: 5,
|
|
43
|
+
description: 'Extraction and classification'
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
const serialized = JSON.stringify(catalog.manifest);
|
|
47
|
+
expect(serialized).to.not.include('worker-secret');
|
|
48
|
+
expect(serialized).to.not.include('private-provider');
|
|
49
|
+
expect(serialized).to.not.include('models');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('rejects invalid workers and unknown selections', () => {
|
|
53
|
+
expect(() => createWorkerCatalog({})).to.throw('non-empty');
|
|
54
|
+
expect(() => createWorkerCatalog({
|
|
55
|
+
invalid: {
|
|
56
|
+
model: {},
|
|
57
|
+
intelligence: 1,
|
|
58
|
+
cost: 1,
|
|
59
|
+
speed: 1,
|
|
60
|
+
description: 'Invalid model'
|
|
61
|
+
}
|
|
62
|
+
})).to.throw('ModelMix instance');
|
|
63
|
+
const catalog = createWorkerCatalog({
|
|
64
|
+
valid: {
|
|
65
|
+
model: workerModel(),
|
|
66
|
+
intelligence: 1,
|
|
67
|
+
cost: 1,
|
|
68
|
+
speed: 1,
|
|
69
|
+
description: 'Valid worker'
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
expect(() => catalog.get('missing')).to.throw('Unknown RLM worker');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('represents the inherited parent chain without exposing a model object', () => {
|
|
76
|
+
const catalog = createWorkerCatalog({
|
|
77
|
+
parent: {
|
|
78
|
+
useParent: true,
|
|
79
|
+
intelligence: 3,
|
|
80
|
+
cost: 2,
|
|
81
|
+
speed: 3,
|
|
82
|
+
description: 'Use the current ModelMix chain'
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(catalog.get('parent')).to.equal(undefined);
|
|
87
|
+
expect(catalog.manifest.parent).to.deep.equal({
|
|
88
|
+
intelligence: 3,
|
|
89
|
+
cost: 2,
|
|
90
|
+
speed: 3,
|
|
91
|
+
description: 'Use the current ModelMix chain'
|
|
92
|
+
});
|
|
93
|
+
expect(() => createWorkerCatalog({
|
|
94
|
+
invalid: {
|
|
95
|
+
model: workerModel(),
|
|
96
|
+
useParent: true,
|
|
97
|
+
intelligence: 1,
|
|
98
|
+
cost: 1,
|
|
99
|
+
speed: 1,
|
|
100
|
+
description: 'Ambiguous worker'
|
|
101
|
+
}
|
|
102
|
+
})).to.throw('exactly one of model or useParent');
|
|
103
|
+
});
|
|
104
|
+
});
|
package/pnpm-workspace.yaml
CHANGED
package/skills/modelmix/SKILL.md
CHANGED
|
@@ -92,6 +92,24 @@ const model = ModelMix.new()
|
|
|
92
92
|
|
|
93
93
|
If `sonnet46` fails, it automatically tries `gpt52`, then `gemini3flash`.
|
|
94
94
|
|
|
95
|
+
### Instance plugins
|
|
96
|
+
|
|
97
|
+
Register middleware with `.use({ name, execute })`. Plugins are scoped to the instance, run in registration order, and receive the rendered provider-neutral request. They may edit `context.request`, call `next()`, or return a complete ModelMix result.
|
|
98
|
+
|
|
99
|
+
```javascript
|
|
100
|
+
model.use({
|
|
101
|
+
name: 'metrics',
|
|
102
|
+
async execute(context, next) {
|
|
103
|
+
const result = await next();
|
|
104
|
+
return { ...result, executionId: context.execution.executionId };
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`context.invoke()` starts a child without conversation history. Its `plugins` policy is `'inherit'`, `'none'`, `{ include: [...] }`, or `{ exclude: [...] }`. Passing `model` routes the child through another ModelMix worker chain while preserving execution-tree metadata. Child invocations may pass `assign` with either `system` or `systemFile`; `systemFile` uses the ordinary ModelMix EJS renderer and relative includes.
|
|
110
|
+
|
|
111
|
+
The optional `@modelmix/rlm` package is a separate workspace/npm package for recursive processing of large structured inputs. Pass Markdown through `documents: { name: { format: 'markdown', content } }`, register named ModelMix worker chains, and provide every runtime limit explicitly. A worker uses either `model: anotherModelMixInstance` or `useParent: true`. Its planner sees content-free variable size/shape metadata, while document values and generated orchestration code stay inside an `isolated-vm` sandbox. RLM planner prompts are Markdown files rendered with the normal child `assign` plus `systemFile` path.
|
|
112
|
+
|
|
95
113
|
### Unified effort
|
|
96
114
|
|
|
97
115
|
Provider-agnostic reasoning intensity. **Not** an `options` field — use `config.effort` or `.effort(n)`.
|
|
@@ -114,11 +132,14 @@ ModelMix.new({ config: { effort: 80 } })
|
|
|
114
132
|
| DeepSeek V4 | off | `low`↑ | `high`↑ | `high`↑ | `max`↑ | — |
|
|
115
133
|
| MiniMax M3 | off | adaptive | adaptive | adaptive | adaptive | adaptive |
|
|
116
134
|
|
|
117
|
-
\* Gemini bands: 0–24 / 25–49 / 50–74 / 75–100. 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.
|
|
135
|
+
\* Gemini bands: 0–24 / 25–49 / 50–74 / 75–100. Gemini 3.7 Flash supports only `low` / `medium` / `high`, so the first two bands clamp to `low`; `-1` keeps its 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.
|
|
118
136
|
|
|
119
137
|
## Available Model Shorthands
|
|
120
138
|
|
|
121
139
|
### OpenAI
|
|
140
|
+
|
|
141
|
+
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.
|
|
142
|
+
|
|
122
143
|
`gpt52()` `gpt52chat()` `gpt51()` `gpt5()` `gpt5mini()` `gpt5nano()` `gpt45()` `gpt41()` `gpt41mini()` `gpt41nano()` `o3()` `o4mini()`
|
|
123
144
|
|
|
124
145
|
### Anthropic
|
|
@@ -127,7 +148,7 @@ ModelMix.new({ config: { effort: 80 } })
|
|
|
127
148
|
Use `.effort(n)` (or `config.effort`) to enable Anthropic thinking — e.g. `.effort(100).opus50()`. `fable5()` and `opus5()` remain available as compatibility aliases.
|
|
128
149
|
|
|
129
150
|
### Google
|
|
130
|
-
`gemini3pro()` `gemini3flash()` `gemini36flash()` `gemini35flash()` `gemini35flashLite()` `gemini31flashLite()` `gemini25pro()` `gemini25flash()`
|
|
151
|
+
`gemini3pro()` `gemini3flash()` `gemini37flash()` `gemini36flash()` `gemini35flash()` `gemini35flashLite()` `gemini31flashLite()` `gemini25pro()` `gemini25flash()`
|
|
131
152
|
|
|
132
153
|
### Grok
|
|
133
154
|
`grok46()` `grok45()` `grok43()` `grok420multiAgent()` `grok420()`
|
|
@@ -302,7 +323,7 @@ const model = ModelMix.new().gpt5mini().addText("Hello!");
|
|
|
302
323
|
const text = await model.message();
|
|
303
324
|
console.log(model.lastRaw.tokens);
|
|
304
325
|
// {
|
|
305
|
-
// input: 1200, output: 50, total: 1250,
|
|
326
|
+
// input: 1200, output: 50, thinking: 0, total: 1250,
|
|
306
327
|
// cached: 1024, cacheWrite: 0, uncachedInput: 176,
|
|
307
328
|
// cacheWrite5m: 0, cacheWrite1h: 0,
|
|
308
329
|
// cacheHitRate: 0.8533, cacheSavings: 0.00018432,
|
|
@@ -586,12 +607,13 @@ const model = ModelMix.new({
|
|
|
586
607
|
| `.addTools([{tool, callback}])` | `this` | Register multiple tools |
|
|
587
608
|
| `.removeTool(name)` | `this` | Remove a tool |
|
|
588
609
|
| `.listTools()` | `{local, mcp}` | List registered tools |
|
|
610
|
+
| `.use(plugin)` | `this` | Register instance-scoped execution middleware |
|
|
589
611
|
| `.new()` | `ModelMix` | Clone instance sharing models |
|
|
590
612
|
| `.attach(key, provider)` | `this` | Attach custom provider |
|
|
591
613
|
|
|
592
614
|
## Available Provider Classes
|
|
593
615
|
|
|
594
|
-
`MixOpenAI` `MixAnthropic` `MixGoogle` `MixPerplexity` `MixGroq` `MixTogether` `MixGrok` `MixOpenRouter` `MixOllama` `MixLMStudio` `MixCustom` `MixCerebras` `MixFireworks` `MixKimi` `MixMiniMax` `MixLambda`
|
|
616
|
+
`ModerationMix` `MixModeration` `MixOpenAI` `MixOpenAIResponses` `MixOpenAIModeration` `MixAnthropic` `MixGoogle` `MixPerplexity` `MixGroq` `MixTogether` `MixGrok` `MixOpenRouter` `MixOllama` `MixLMStudio` `MixCustom` `MixCerebras` `MixFireworks` `MixKimi` `MixMiniMax` `MixLambda`
|
|
595
617
|
|
|
596
618
|
## Troubleshooting
|
|
597
619
|
|
package/test/effort.test.js
CHANGED
|
@@ -121,6 +121,19 @@ describe('Unified effort scale', () => {
|
|
|
121
121
|
});
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
+
it('clamps Gemini 3.7 Flash to low, medium, and high', () => {
|
|
125
|
+
expect(mapEffort('google', 0, 'gemini-3.7-flash')).to.deep.equal({
|
|
126
|
+
thinkingConfig: { thinkingLevel: 'low' }
|
|
127
|
+
});
|
|
128
|
+
expect(mapEffort('google', 50, 'gemini-3.7-flash')).to.deep.equal({
|
|
129
|
+
thinkingConfig: { thinkingLevel: 'medium' }
|
|
130
|
+
});
|
|
131
|
+
expect(mapEffort('google', 100, 'gemini-3.7-flash')).to.deep.equal({
|
|
132
|
+
thinkingConfig: { thinkingLevel: 'high' }
|
|
133
|
+
});
|
|
134
|
+
expect(mapEffort('google', -1, 'gemini-3.7-flash')).to.equal(null);
|
|
135
|
+
});
|
|
136
|
+
|
|
124
137
|
it('clamps Gemini levels for models with fewer steps', () => {
|
|
125
138
|
expect(mapEffort('google', 10, 'gemini-3-pro-preview')).to.deep.equal({
|
|
126
139
|
thinkingConfig: { thinkingLevel: 'low' }
|
|
@@ -384,7 +397,7 @@ describe('Unified effort scale', () => {
|
|
|
384
397
|
await google.create({
|
|
385
398
|
config: { system: 'sys' },
|
|
386
399
|
options: {
|
|
387
|
-
model: 'gemini-3.
|
|
400
|
+
model: 'gemini-3.7-flash',
|
|
388
401
|
max_tokens: 100,
|
|
389
402
|
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
|
|
390
403
|
thinkingConfig: { thinkingLevel: 'low' }
|
package/test/live.mcp.js
CHANGED
|
@@ -181,8 +181,8 @@ describe('Live MCP Integration Tests', function () {
|
|
|
181
181
|
}
|
|
182
182
|
});
|
|
183
183
|
|
|
184
|
-
it('should use custom MCP tools with Gemini 3 Flash', async function () {
|
|
185
|
-
const model = ModelMix.new(setup).
|
|
184
|
+
it('should use custom MCP tools with Gemini 3.7 Flash', async function () {
|
|
185
|
+
const model = ModelMix.new(setup).gemini37flash();
|
|
186
186
|
|
|
187
187
|
// Add password generator tool
|
|
188
188
|
model.addTool({
|
|
@@ -220,7 +220,7 @@ describe('Live MCP Integration Tests', function () {
|
|
|
220
220
|
model.addText('Generate a secure password of 16 characters with symbols.');
|
|
221
221
|
|
|
222
222
|
const response = await model.message();
|
|
223
|
-
console.log(`Gemini 3 Flash with MCP tools: ${response}`);
|
|
223
|
+
console.log(`Gemini 3.7 Flash with MCP tools: ${response}`);
|
|
224
224
|
|
|
225
225
|
expect(response).to.be.a('string');
|
|
226
226
|
// Check password is mentioned and a generated password string is present
|
|
@@ -407,8 +407,8 @@ describe('Live MCP Integration Tests', function () {
|
|
|
407
407
|
expect(result.factorial_result).to.equal(120);
|
|
408
408
|
});
|
|
409
409
|
|
|
410
|
-
it('should use MCP tools with JSON output using Gemini 3 Flash', async function () {
|
|
411
|
-
const model = ModelMix.new(setup).
|
|
410
|
+
it('should use MCP tools with JSON output using Gemini 3.7 Flash', async function () {
|
|
411
|
+
const model = ModelMix.new(setup).gemini37flash();
|
|
412
412
|
|
|
413
413
|
// Add system info tool
|
|
414
414
|
model.addTool({
|
|
@@ -447,7 +447,7 @@ describe('Live MCP Integration Tests', function () {
|
|
|
447
447
|
generated_at: ""
|
|
448
448
|
});
|
|
449
449
|
|
|
450
|
-
console.log(`Gemini 3 Flash with MCP tools JSON result:`, result);
|
|
450
|
+
console.log(`Gemini 3.7 Flash with MCP tools JSON result:`, result);
|
|
451
451
|
|
|
452
452
|
expect(result).to.be.an('object');
|
|
453
453
|
expect(result.timestamp).to.be.a('number');
|
package/test/live.test.js
CHANGED
|
@@ -59,7 +59,7 @@ describe('Live Integration Tests', function () {
|
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
it('should process images with Google Gemini', async function () {
|
|
62
|
-
const model = ModelMix.new(setup).
|
|
62
|
+
const model = ModelMix.new(setup).gemini37flash();
|
|
63
63
|
|
|
64
64
|
model.addImageFromUrl(blueSquareBase64)
|
|
65
65
|
.addText('What color is this image? Answer in one word only.');
|
|
@@ -120,7 +120,7 @@ describe('Live Integration Tests', function () {
|
|
|
120
120
|
});
|
|
121
121
|
|
|
122
122
|
it('should return structured JSON with Google Gemini', async function () {
|
|
123
|
-
const model = ModelMix.new(setup).
|
|
123
|
+
const model = ModelMix.new(setup).gemini37flash();
|
|
124
124
|
|
|
125
125
|
model.addText('Generate information about a fictional city.');
|
|
126
126
|
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
const { expect } = require('chai');
|
|
2
|
+
const nock = require('nock');
|
|
3
|
+
const { ModerationMix, MixModeration, MixOpenAIModeration } = require('../index.js');
|
|
4
|
+
|
|
5
|
+
describe('OpenAI moderation', () => {
|
|
6
|
+
const moderationResult = {
|
|
7
|
+
flagged: true,
|
|
8
|
+
categories: { violence: true },
|
|
9
|
+
category_scores: { violence: 0.98 },
|
|
10
|
+
category_applied_input_types: { violence: ['text', 'image'] }
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
it('registers omni-moderation-latest with openai()', () => {
|
|
14
|
+
const model = ModerationMix.new().openai({ config: { apiKey: 'test-key' } });
|
|
15
|
+
|
|
16
|
+
expect(model.models).to.have.length(1);
|
|
17
|
+
expect(model.models[0].key).to.equal('omni-moderation-latest');
|
|
18
|
+
expect(model.models[0].provider).to.be.instanceOf(MixOpenAIModeration);
|
|
19
|
+
expect(model.models[0].provider.config.url).to.equal('https://api.openai.com/v1/moderations');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('accepts an explicit API key without requiring the environment variable', () => {
|
|
23
|
+
const originalApiKey = process.env.OPENAI_API_KEY;
|
|
24
|
+
delete process.env.OPENAI_API_KEY;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const model = ModerationMix.new().openai({ config: { apiKey: 'explicit-key' } });
|
|
28
|
+
expect(model.models[0].provider.config.apiKey).to.equal('explicit-key');
|
|
29
|
+
} finally {
|
|
30
|
+
if (originalApiKey === undefined) delete process.env.OPENAI_API_KEY;
|
|
31
|
+
else process.env.OPENAI_API_KEY = originalApiKey;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('sends text and image input to the Moderations endpoint', async () => {
|
|
36
|
+
const api = nock('https://api.openai.com')
|
|
37
|
+
.post('/v1/moderations', body => {
|
|
38
|
+
expect(body).to.deep.equal({
|
|
39
|
+
model: 'omni-moderation-latest',
|
|
40
|
+
input: [
|
|
41
|
+
{ type: 'text', text: 'Check this' },
|
|
42
|
+
{
|
|
43
|
+
type: 'image_url',
|
|
44
|
+
image_url: { url: 'data:image/png;base64,aW1hZ2U=' }
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
});
|
|
48
|
+
return true;
|
|
49
|
+
})
|
|
50
|
+
.reply(200, {
|
|
51
|
+
id: 'modr-test',
|
|
52
|
+
model: 'omni-moderation-latest',
|
|
53
|
+
results: [moderationResult]
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const result = await ModerationMix.new()
|
|
57
|
+
.openai({ config: { apiKey: 'test-key' } })
|
|
58
|
+
.addText('Check this')
|
|
59
|
+
.addImageFromUrl('data:image/png;base64,aW1hZ2U=')
|
|
60
|
+
.raw();
|
|
61
|
+
|
|
62
|
+
expect(result.moderation).to.deep.equal([moderationResult]);
|
|
63
|
+
api.done();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('exposes the complete API response through raw()', async () => {
|
|
67
|
+
const response = {
|
|
68
|
+
id: 'modr-test',
|
|
69
|
+
model: 'omni-moderation-latest',
|
|
70
|
+
results: [moderationResult]
|
|
71
|
+
};
|
|
72
|
+
const api = nock('https://api.openai.com')
|
|
73
|
+
.post('/v1/moderations')
|
|
74
|
+
.reply(200, response);
|
|
75
|
+
|
|
76
|
+
const raw = await ModerationMix.new()
|
|
77
|
+
.openai({ config: { apiKey: 'test-key' } })
|
|
78
|
+
.addText('Check this')
|
|
79
|
+
.raw();
|
|
80
|
+
|
|
81
|
+
expect(raw.moderation).to.deep.equal(response.results);
|
|
82
|
+
expect(raw.response).to.deep.equal(response);
|
|
83
|
+
expect(raw.tokens).to.include({ input: 0, output: 0, total: 0, cost: 0 });
|
|
84
|
+
api.done();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('rejects streaming because the Moderations endpoint does not support it', async () => {
|
|
88
|
+
const model = ModerationMix.new()
|
|
89
|
+
.openai({ config: { apiKey: 'test-key' } })
|
|
90
|
+
.addText('Check this');
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
await model.stream(() => {});
|
|
94
|
+
throw new Error('Expected stream() to reject');
|
|
95
|
+
} catch (error) {
|
|
96
|
+
expect(error.message).to.equal('ModerationMix does not support streaming. Use raw().');
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('rejects generative providers from the moderation chain', () => {
|
|
101
|
+
const model = ModerationMix.new();
|
|
102
|
+
|
|
103
|
+
expect(() => model.gpt41nano()).to.throw(
|
|
104
|
+
'ModerationMix only accepts moderation providers.'
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('accepts additional moderation providers as fallbacks', () => {
|
|
109
|
+
class TestModeration extends MixModeration {}
|
|
110
|
+
const model = ModerationMix.new()
|
|
111
|
+
.openai({ config: { apiKey: 'test-key' } })
|
|
112
|
+
.attach('test-moderation', new TestModeration({ config: { apiKey: 'test-key' } }));
|
|
113
|
+
|
|
114
|
+
expect(model.models.map(({ key }) => key)).to.deep.equal([
|
|
115
|
+
'omni-moderation-latest',
|
|
116
|
+
'test-moderation'
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
for (const method of ['message', 'json', 'block']) {
|
|
121
|
+
it(`rejects ${method}() because moderation is not generative`, async () => {
|
|
122
|
+
const model = ModerationMix.new()
|
|
123
|
+
.openai({ config: { apiKey: 'test-key' } })
|
|
124
|
+
.addText('Check this');
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
await model[method]();
|
|
128
|
+
throw new Error(`Expected ${method}() to reject`);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
expect(error.message).to.include('ModerationMix does not generate');
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
});
|