modelmix 5.2.0 → 5.2.2
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 +28 -0
- package/demo/benchmark.js +2 -2
- package/demo/grok.js +1 -1
- package/index.d.ts +2 -5
- package/index.js +48 -7
- package/lib/model-chain.js +1 -1
- package/lib/providers/openai.js +47 -7
- package/package.json +4 -2
- 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 +22 -1
- package/test/grok.test.js +33 -0
- package/test/plugins.test.js +150 -1
package/README.md
CHANGED
|
@@ -193,6 +193,7 @@ ModelMix provides convenient shorthand methods for quickly accessing different A
|
|
|
193
193
|
| `gemini35flash()` | Google | gemini-3.5-flash | [\$0.75][3] | [\$4.50][3] |
|
|
194
194
|
| `gemini35flashLite()` | Google | gemini-3.5-flash-lite | [\$0.30][3] | [\$2.50][3] |
|
|
195
195
|
| `gemini31flashLite()` | Google | gemini-3.1-flash-lite-preview | [\$0.25][3] | [\$1.50][3] |
|
|
196
|
+
| `grok47()` | Grok | grok-4.7 | — | — |
|
|
196
197
|
| `grok46()` | Grok | grok-4.6 | [\$2.00][6] | [\$6.00][6] |
|
|
197
198
|
| `grok45()` | Grok | grok-4.5 | [\$2.00][6] | [\$6.00][6] |
|
|
198
199
|
| `grok43()` | Grok | grok-4.3 | [\$1.25][6] | [\$2.50][6] |
|
|
@@ -1000,6 +1001,33 @@ Supported policies are `'inherit'`, `'none'`, `{ include: [...] }`, and `{ exclu
|
|
|
1000
1001
|
|
|
1001
1002
|
Child `systemFile` templates use the same EJS engine, `assign()` data contract, and relative Markdown includes as ordinary ModelMix templates. Use either `system` or `systemFile`, not both.
|
|
1002
1003
|
|
|
1004
|
+
Plugins may append `{ tool, callback }` entries to `context.request.tools` using the same definitions as `addTools()`. These tools are available for that execution and its tool continuations, alongside registered local/MCP tools. They do not change the instance's tool registry. Duplicate names are rejected before calling a provider. Child invocations rebuild their tools through the selected plugins or explicit `tools` input.
|
|
1005
|
+
|
|
1006
|
+
When plugins add tools, native `options.tools` entries are combined with registered and plugin tools; duplicate function names are rejected. OpenAI Responses converts function definitions and preserves tool calls, results, and accompanying reasoning across continuations.
|
|
1007
|
+
|
|
1008
|
+
### Skills plugin
|
|
1009
|
+
|
|
1010
|
+
The included plugin loads local [Agent Skills](https://agentskills.io/specification). Pass explicit skill directories or `SKILL.md` files:
|
|
1011
|
+
|
|
1012
|
+
```javascript
|
|
1013
|
+
import { ModelMix } from 'modelmix';
|
|
1014
|
+
import { skills } from 'modelmix/plugins/skills/index.js';
|
|
1015
|
+
|
|
1016
|
+
const model = ModelMix.new()
|
|
1017
|
+
.gpt6astra()
|
|
1018
|
+
.opus5()
|
|
1019
|
+
.use(await skills({ paths: ['./skills/writing', './skills/research/SKILL.md'] }))
|
|
1020
|
+
.addText('Use the writing skill to improve this paragraph: ...');
|
|
1021
|
+
|
|
1022
|
+
console.log(await model.message());
|
|
1023
|
+
```
|
|
1024
|
+
|
|
1025
|
+
`skills()` is asynchronous. Each file must have YAML frontmatter with non-empty string `name` and `description` fields; duplicate names and malformed files fail during loading. Relative paths resolve from `process.cwd()`.
|
|
1026
|
+
|
|
1027
|
+
Only names and descriptions are appended to the system prompt. A model supporting tool calls can select a skill with `read_skill({ name })`, then load supporting UTF-8 text files with `read_skill({ name, path: 'references/style.md' })`. Full instructions are returned literally, including any EJS syntax. Existing system instructions and tools are preserved; `read_skill` is reserved while this plugin runs.
|
|
1028
|
+
|
|
1029
|
+
Skill metadata and `SKILL.md` content are snapshots taken when creating the plugin; recreate it to reload edits. Supporting files are read on demand. Reads must stay inside the registered skill directory, including resolved symlinks. Files are returned in full, so callers should register appropriately sized, trusted skills. The plugin does not execute scripts, install tools, or grant permissions from `allowed-tools` metadata. Skills requiring additional capabilities need tools supplied by the application.
|
|
1030
|
+
|
|
1003
1031
|
### Benchmark plugin
|
|
1004
1032
|
|
|
1005
1033
|
The included benchmark plugin derives task-specific criteria, runs each configured model independently, and uses the other distinct models as anonymous judges. Model specifications use the same `shortcut@effort` syntax as `chain()`:
|
package/demo/benchmark.js
CHANGED
|
@@ -43,7 +43,7 @@ await mkdir(resultsDirectory, { recursive: true });
|
|
|
43
43
|
for (const result of report.results) {
|
|
44
44
|
if (result.response === null) continue;
|
|
45
45
|
const filename = result.id.replace(/[^A-Za-z0-9_-]/g, '_');
|
|
46
|
-
const contents = `# ${result.id}\n\nScore: ${result.score === null ? 'N/A' :
|
|
46
|
+
const contents = `# ${result.id}\n\nScore: ${result.score === null ? 'N/A' : Math.round(result.score * 10)}/100\n\nEstimated generation cost (USD): ${result.responseMetrics?.cost ?? 'N/A'}\n\n---\n\n${result.response}\n`;
|
|
47
47
|
await writeFile(path.join(resultsDirectory, `${filename}.md`), contents, 'utf8');
|
|
48
48
|
}
|
|
49
49
|
|
|
@@ -69,7 +69,7 @@ const ranking = report.results
|
|
|
69
69
|
if (right.score === null) return -1;
|
|
70
70
|
return right.score - left.score;
|
|
71
71
|
})
|
|
72
|
-
.map(row => ({ ...row, score: row.score === null ? null :
|
|
72
|
+
.map(row => ({ ...row, score: row.score === null ? null : Math.round(row.score * 10) }));
|
|
73
73
|
|
|
74
74
|
console.table(ranking);
|
|
75
75
|
if (report.errors.length > 0) {
|
package/demo/grok.js
CHANGED
package/index.d.ts
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Type definitions for modelmix
|
|
3
|
-
* @see https://github.com/clasen/ModelMix
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
1
|
export type MessageRole = 'user' | 'assistant' | 'system' | 'tool' | string;
|
|
7
2
|
|
|
8
3
|
export type DebugLevel = 0 | 1 | 2 | 3 | 4;
|
|
@@ -258,6 +253,7 @@ export interface PluginExecutionContext {
|
|
|
258
253
|
request: {
|
|
259
254
|
system: string;
|
|
260
255
|
messages: ChatMessage[];
|
|
256
|
+
tools: ToolWithCallback[];
|
|
261
257
|
options: ModelMixOptions;
|
|
262
258
|
config: ModelMixConfig;
|
|
263
259
|
outputMode: ModelMixOutputMode;
|
|
@@ -499,6 +495,7 @@ export declare class ModelMix {
|
|
|
499
495
|
sonar(args?: ModelAttachArgs): this;
|
|
500
496
|
|
|
501
497
|
// Grok
|
|
498
|
+
grok47(args?: ModelAttachArgs): this;
|
|
502
499
|
grok46(args?: ModelAttachArgs): this;
|
|
503
500
|
grok45(args?: ModelAttachArgs): this;
|
|
504
501
|
grok43(args?: ModelAttachArgs): this;
|
package/index.js
CHANGED
|
@@ -603,6 +603,9 @@ class ModelMix {
|
|
|
603
603
|
return this.attach('sonar', new MixPerplexity({ options, config }));
|
|
604
604
|
}
|
|
605
605
|
|
|
606
|
+
grok47({ options = {}, config = {} } = {}) {
|
|
607
|
+
return this.attach('grok-4.7', new MixGrok({ options, config }));
|
|
608
|
+
}
|
|
606
609
|
grok46({ options = {}, config = {} } = {}) {
|
|
607
610
|
return this.attach('grok-4.6', new MixGrok({ options, config }));
|
|
608
611
|
}
|
|
@@ -1312,6 +1315,7 @@ class ModelMix {
|
|
|
1312
1315
|
const request = {
|
|
1313
1316
|
system: this._renderSystem(config, {}, systemSuffix, templateContext),
|
|
1314
1317
|
messages: clonePluginValue(preparedMessages),
|
|
1318
|
+
tools: [],
|
|
1315
1319
|
options: clonePluginValue({ ...this.options, ...options }),
|
|
1316
1320
|
config: clonePluginValue(this._mergeRequestConfig(config)),
|
|
1317
1321
|
outputMode
|
|
@@ -1383,15 +1387,34 @@ class ModelMix {
|
|
|
1383
1387
|
templateContext
|
|
1384
1388
|
}) {
|
|
1385
1389
|
const provider = currentModel.provider;
|
|
1390
|
+
const tools = pluginRequest?.tools.length ? {
|
|
1391
|
+
...this.tools,
|
|
1392
|
+
local: [...(this.tools.local || []), ...pluginRequest.tools.map(entry => entry.tool)]
|
|
1393
|
+
} : this.tools;
|
|
1394
|
+
const toolOptions = provider.getOptionsTools(tools);
|
|
1386
1395
|
const currentOptions = {
|
|
1387
1396
|
...this.options,
|
|
1388
1397
|
messages: preparedMessages,
|
|
1389
1398
|
...provider.options,
|
|
1390
|
-
...
|
|
1399
|
+
...toolOptions,
|
|
1391
1400
|
...options,
|
|
1392
1401
|
...(pluginRequest?.options || {}),
|
|
1393
1402
|
model: currentModel.key
|
|
1394
1403
|
};
|
|
1404
|
+
if (pluginRequest?.tools.length && currentOptions.tools !== toolOptions.tools) {
|
|
1405
|
+
if (!Array.isArray(currentOptions.tools)) {
|
|
1406
|
+
throw new TypeError('Request options.tools must be an array when using plugin tools.');
|
|
1407
|
+
}
|
|
1408
|
+
currentOptions.tools = [...(toolOptions.tools || []), ...currentOptions.tools];
|
|
1409
|
+
const names = new Set();
|
|
1410
|
+
for (const tool of currentOptions.tools) {
|
|
1411
|
+
for (const definition of tool.functionDeclarations || [tool.function || tool]) {
|
|
1412
|
+
if (!definition.name) continue;
|
|
1413
|
+
if (names.has(definition.name)) throw new Error(`Duplicate tool name: ${definition.name}`);
|
|
1414
|
+
names.add(definition.name);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1395
1418
|
const currentConfig = pluginRequest
|
|
1396
1419
|
? {
|
|
1397
1420
|
...provider.config,
|
|
@@ -1512,7 +1535,7 @@ class ModelMix {
|
|
|
1512
1535
|
result.tokens.speed = elapsedSec > 0 ? Math.round(result.tokens.output / elapsedSec) : 0;
|
|
1513
1536
|
}
|
|
1514
1537
|
|
|
1515
|
-
async _continueToolCalls(result, pluginRequest, execution) {
|
|
1538
|
+
async _continueToolCalls(result, pluginRequest, execution, pluginTools) {
|
|
1516
1539
|
const originalMessages = this.messages;
|
|
1517
1540
|
const toolMessages = pluginRequest
|
|
1518
1541
|
? clonePluginValue(pluginRequest.messages)
|
|
@@ -1540,7 +1563,7 @@ class ModelMix {
|
|
|
1540
1563
|
if (!result.assistantMessage) {
|
|
1541
1564
|
toolMessages.push({ role: 'assistant', content: null, tool_calls: result.toolCalls });
|
|
1542
1565
|
}
|
|
1543
|
-
const toolResults = await this.processToolCalls(result.toolCalls, execution.signal);
|
|
1566
|
+
const toolResults = await this.processToolCalls(result.toolCalls, execution.signal, pluginTools);
|
|
1544
1567
|
for (const toolResult of toolResults) {
|
|
1545
1568
|
toolMessages.push({
|
|
1546
1569
|
role: 'tool',
|
|
@@ -1650,6 +1673,23 @@ class ModelMix {
|
|
|
1650
1673
|
this._requirePreparedMessages(preparedMessages);
|
|
1651
1674
|
|
|
1652
1675
|
const finalConfig = pluginRequest ? pluginRequest.config : this._mergeRequestConfig(config);
|
|
1676
|
+
const pluginTools = pluginRequest ? new MCPToolsManager() : null;
|
|
1677
|
+
if (pluginRequest) {
|
|
1678
|
+
if (!Array.isArray(pluginRequest.tools)) {
|
|
1679
|
+
throw new TypeError('Plugin request tools must be an array.');
|
|
1680
|
+
}
|
|
1681
|
+
const names = new Set(Object.values(this.tools).flat().map(tool => tool.name));
|
|
1682
|
+
for (const entry of pluginRequest.tools) {
|
|
1683
|
+
if (!isPlainObject(entry) || !isPlainObject(entry.tool)) {
|
|
1684
|
+
throw new TypeError('Plugin request tools must contain { tool, callback }.');
|
|
1685
|
+
}
|
|
1686
|
+
if (names.has(entry.tool.name)) {
|
|
1687
|
+
throw new Error(`Duplicate tool name: ${entry.tool.name}`);
|
|
1688
|
+
}
|
|
1689
|
+
pluginTools.registerTool(entry.tool, entry.callback);
|
|
1690
|
+
names.add(entry.tool.name);
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1653
1693
|
const modelsToTry = this.models.map((model, index) => ({ model, index }));
|
|
1654
1694
|
if (finalConfig.roundRobin && this.models.length > 1) {
|
|
1655
1695
|
this.models.push(this.models.shift());
|
|
@@ -1695,7 +1735,7 @@ class ModelMix {
|
|
|
1695
1735
|
_templateContext: templateContext,
|
|
1696
1736
|
_executionMetadata: executionMetadata,
|
|
1697
1737
|
_pluginsApplied: pluginsApplied
|
|
1698
|
-
});
|
|
1738
|
+
}, pluginTools);
|
|
1699
1739
|
}
|
|
1700
1740
|
|
|
1701
1741
|
this._logProviderSuccess(result, providerAttempt.currentConfig);
|
|
@@ -1771,7 +1811,7 @@ class ModelMix {
|
|
|
1771
1811
|
if (isRootExecution) this._commitTemplateRenderContext(templateContext);
|
|
1772
1812
|
return result;
|
|
1773
1813
|
}
|
|
1774
|
-
async processToolCalls(toolCalls, signal) {
|
|
1814
|
+
async processToolCalls(toolCalls, signal, pluginTools) {
|
|
1775
1815
|
assertAbortSignal(signal);
|
|
1776
1816
|
const result = []
|
|
1777
1817
|
|
|
@@ -1804,8 +1844,9 @@ class ModelMix {
|
|
|
1804
1844
|
}
|
|
1805
1845
|
|
|
1806
1846
|
// Verificar si es una herramienta local registrada
|
|
1807
|
-
if (this.mcpToolsManager.hasTool(toolName)) {
|
|
1808
|
-
const
|
|
1847
|
+
if (pluginTools?.hasTool(toolName) || this.mcpToolsManager.hasTool(toolName)) {
|
|
1848
|
+
const manager = pluginTools?.hasTool(toolName) ? pluginTools : this.mcpToolsManager;
|
|
1849
|
+
const response = await manager.executeTool(toolName, toolArgs, signal);
|
|
1809
1850
|
throwIfAborted(signal);
|
|
1810
1851
|
result.push({
|
|
1811
1852
|
name: toolName,
|
package/lib/model-chain.js
CHANGED
|
@@ -9,7 +9,7 @@ const CHAIN_MODEL_SHORTCUTS = new Set([
|
|
|
9
9
|
'sonnet50', 'sonnet5', 'sonnet46', 'sonnet45', 'haiku45',
|
|
10
10
|
'gemini31pro', 'gemini38flash', 'gemini37flash', 'gemini36flash', 'gemini35flash',
|
|
11
11
|
'gemini35flashLite', 'gemini31flashLite', 'sonarPro', 'sonar',
|
|
12
|
-
'grok46', 'grok45', 'grok43', 'grok420multiAgent', 'grok420',
|
|
12
|
+
'grok47', 'grok46', 'grok45', 'grok43', 'grok420multiAgent', 'grok420',
|
|
13
13
|
'museGlimmer30b', 'museSpark12', 'museSpark12c', 'museSpark13', 'museSpark13c',
|
|
14
14
|
'qwen35397b', 'qwen36plus', 'qwen37plus', 'qwen38max', 'qwen3827b', 'qwen38flash',
|
|
15
15
|
'hermes470b', 'hermes4405b', 'hermes3',
|
package/lib/providers/openai.js
CHANGED
|
@@ -54,6 +54,17 @@ function createOpenAIProviders({
|
|
|
54
54
|
|
|
55
55
|
if (options.reasoning_effort) request.reasoning = { effort: options.reasoning_effort };
|
|
56
56
|
if (options.verbosity) request.text = { verbosity: options.verbosity };
|
|
57
|
+
if (options.tools !== undefined) {
|
|
58
|
+
request.tools = options.tools.map(tool => tool.type === 'function' && tool.function
|
|
59
|
+
? { type: 'function', strict: false, ...tool.function }
|
|
60
|
+
: tool);
|
|
61
|
+
}
|
|
62
|
+
if (options.tool_choice !== undefined) {
|
|
63
|
+
request.tool_choice = options.tool_choice?.function
|
|
64
|
+
? { type: 'function', name: options.tool_choice.function.name }
|
|
65
|
+
: options.tool_choice;
|
|
66
|
+
}
|
|
67
|
+
if (options.parallel_tool_calls !== undefined) request.parallel_tool_calls = options.parallel_tool_calls;
|
|
57
68
|
|
|
58
69
|
if (options.response_format) {
|
|
59
70
|
const rf = options.response_format;
|
|
@@ -138,10 +149,25 @@ function createOpenAIProviders({
|
|
|
138
149
|
static processResponsesResponse(response) {
|
|
139
150
|
MixCustom.assertResponse(response.data);
|
|
140
151
|
const message = MixOpenAIResponses.extractResponsesMessage(response.data);
|
|
152
|
+
const toolCalls = (response.data.output || [])
|
|
153
|
+
.filter(item => item.type === 'function_call')
|
|
154
|
+
.map(item => ({
|
|
155
|
+
id: item.call_id,
|
|
156
|
+
type: 'function',
|
|
157
|
+
function: { name: item.name, arguments: item.arguments }
|
|
158
|
+
}));
|
|
141
159
|
return {
|
|
142
160
|
message,
|
|
143
161
|
think: null,
|
|
144
|
-
toolCalls
|
|
162
|
+
toolCalls,
|
|
163
|
+
...(toolCalls.length > 0 && {
|
|
164
|
+
assistantMessage: {
|
|
165
|
+
role: 'assistant',
|
|
166
|
+
content: message || null,
|
|
167
|
+
tool_calls: toolCalls,
|
|
168
|
+
_responsesOutput: response.data.output
|
|
169
|
+
}
|
|
170
|
+
}),
|
|
145
171
|
tokens: MixOpenAIResponses.extractResponsesTokens(response.data),
|
|
146
172
|
response: response.data
|
|
147
173
|
};
|
|
@@ -179,7 +205,14 @@ function createOpenAIProviders({
|
|
|
179
205
|
|
|
180
206
|
for (const message of messages) {
|
|
181
207
|
if (!message || !message.role) continue;
|
|
182
|
-
if (message.
|
|
208
|
+
if (message.role === 'assistant' && Array.isArray(message._responsesOutput)) {
|
|
209
|
+
mapped.push(...message._responsesOutput);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (message.role === 'tool') {
|
|
213
|
+
mapped.push({ type: 'function_call_output', call_id: message.tool_call_id, output: message.content });
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
183
216
|
|
|
184
217
|
const content = [];
|
|
185
218
|
const isAssistant = message.role === 'assistant';
|
|
@@ -234,11 +267,18 @@ function createOpenAIProviders({
|
|
|
234
267
|
}
|
|
235
268
|
}
|
|
236
269
|
|
|
237
|
-
if (content.length
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
270
|
+
if (content.length > 0) {
|
|
271
|
+
mapped.push({ role: message.role, content });
|
|
272
|
+
}
|
|
273
|
+
for (const call of message.tool_calls || []) {
|
|
274
|
+
const args = call.function ? call.function.arguments : call.input ?? call.arguments ?? {};
|
|
275
|
+
mapped.push({
|
|
276
|
+
type: 'function_call',
|
|
277
|
+
call_id: call.id,
|
|
278
|
+
name: call.function ? call.function.name : call.name,
|
|
279
|
+
arguments: typeof args === 'string' ? args : JSON.stringify(args)
|
|
280
|
+
});
|
|
281
|
+
}
|
|
242
282
|
}
|
|
243
283
|
|
|
244
284
|
return mapped;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "modelmix",
|
|
3
|
-
"version": "5.2.
|
|
3
|
+
"version": "5.2.2",
|
|
4
4
|
"description": "🧬 Reliable interface with automatic fallback for AI LLMs.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"bottleneck": "^2.19.5",
|
|
57
57
|
"ejs": "6.0.1",
|
|
58
58
|
"file-type": "^21.3.4",
|
|
59
|
+
"js-yaml": "4.3.2",
|
|
59
60
|
"lemonlog": "^1.2.2",
|
|
60
61
|
"ws": "^8.21.1"
|
|
61
62
|
},
|
|
@@ -77,8 +78,9 @@
|
|
|
77
78
|
"test:live.mcp": "mocha test/live.mcp.js --timeout 60000 --require test/setup.js",
|
|
78
79
|
"test:tokens": "mocha test/tokens.test.js --timeout 10000 --require test/setup.js",
|
|
79
80
|
"test:plugins": "mocha test/plugins.test.js --timeout 10000 --require test/setup.js",
|
|
81
|
+
"test:skills": "mocha plugins/skills/test/**/*.test.js --timeout 10000 --require test/setup.js",
|
|
80
82
|
"test:benchmark": "mocha plugins/benchmark/test/**/*.test.js --timeout 10000 --require test/setup.js",
|
|
81
83
|
"test:rlm": "mocha plugins/rlm/test/**/*.test.js --timeout 10000 --require test/setup.js",
|
|
82
|
-
"test:offline": "mocha test/abort.test.js test/json.test.js test/fallback.test.js test/templates.test.js test/images.test.js test/bottleneck.test.js test/tokens.test.js test/history.test.js test/anthropic.test.js test/effort.test.js test/grok.test.js test/google.test.js test/moderation.test.js test/plugins.test.js plugins/benchmark/test/**/*.test.js plugins/rlm/test/**/*.test.js --timeout 10000 --require test/setup.js"
|
|
84
|
+
"test:offline": "mocha test/abort.test.js test/json.test.js test/fallback.test.js test/templates.test.js test/images.test.js test/bottleneck.test.js test/tokens.test.js test/history.test.js test/anthropic.test.js test/effort.test.js test/grok.test.js test/google.test.js test/moderation.test.js test/plugins.test.js plugins/skills/test/**/*.test.js plugins/benchmark/test/**/*.test.js plugins/rlm/test/**/*.test.js --timeout 10000 --require test/setup.js"
|
|
83
85
|
}
|
|
84
86
|
}
|
|
@@ -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 };
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { ModelMix, MixCustom } = require('../../..');
|
|
6
|
+
const { skills } = require('..');
|
|
7
|
+
|
|
8
|
+
describe('Skills plugin', () => {
|
|
9
|
+
let temporary;
|
|
10
|
+
let directory;
|
|
11
|
+
const source = '---\nname: writing\ndescription: >-\n Write clear prose\n for readers.\nmetadata:\n category: editorial\n---\nUse references/style.md. Preserve <%= literal %> and ${text}.\n';
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'modelmix-skills-'));
|
|
15
|
+
directory = path.join(temporary, 'writing');
|
|
16
|
+
await fs.mkdir(path.join(directory, 'references'), { recursive: true });
|
|
17
|
+
await fs.writeFile(path.join(directory, 'SKILL.md'), source);
|
|
18
|
+
await fs.writeFile(path.join(directory, 'references/style.md'), 'Use concrete verbs.');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await fs.rm(temporary, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
async function prepare(plugin) {
|
|
26
|
+
const request = { system: 'Existing system', tools: [] };
|
|
27
|
+
await plugin.execute({ request }, async () => ({ message: 'ok' }));
|
|
28
|
+
return request;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
it('loads only metadata into the prompt and exposes literal skill content on demand', async () => {
|
|
32
|
+
const plugin = await skills({ paths: [directory] });
|
|
33
|
+
const request = await prepare(plugin);
|
|
34
|
+
assert.ok(request.system.startsWith('Existing system\n\n'));
|
|
35
|
+
assert.ok(request.system.includes('Write clear prose for readers.'));
|
|
36
|
+
assert.ok(!request.system.includes('Preserve <%= literal %>'));
|
|
37
|
+
const result = await request.tools[0].callback({ name: 'writing' });
|
|
38
|
+
assert.deepEqual(result, { name: 'writing', path: 'SKILL.md', content: source });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('supports SKILL.md paths and reads references relative to the skill root', async () => {
|
|
42
|
+
const request = await prepare(await skills({ paths: [path.join(directory, 'SKILL.md')] }));
|
|
43
|
+
assert.deepEqual(await request.tools[0].callback({ name: 'writing', path: 'references/style.md' }), {
|
|
44
|
+
name: 'writing', path: 'references/style.md', content: 'Use concrete verbs.'
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns the loaded snapshot for every path that resolves to SKILL.md', async () => {
|
|
49
|
+
await fs.symlink(path.join(directory, 'SKILL.md'), path.join(directory, 'alias.md'));
|
|
50
|
+
const request = await prepare(await skills({ paths: [directory] }));
|
|
51
|
+
await fs.writeFile(path.join(directory, 'SKILL.md'), '---\nname: writing\ndescription: Changed\n---\nChanged body.\n');
|
|
52
|
+
for (const relative of ['./SKILL.md', 'references/../SKILL.md', 'alias.md']) {
|
|
53
|
+
assert.deepEqual(await request.tools[0].callback({ name: 'writing', path: relative }), {
|
|
54
|
+
name: 'writing', path: relative, content: source
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('reads supporting files from disk on every call', async () => {
|
|
60
|
+
const request = await prepare(await skills({ paths: [directory] }));
|
|
61
|
+
await fs.writeFile(path.join(directory, 'references/style.md'), 'Use strong verbs.');
|
|
62
|
+
assert.deepEqual(await request.tools[0].callback({ name: 'writing', path: 'references/style.md' }), {
|
|
63
|
+
name: 'writing', path: 'references/style.md', content: 'Use strong verbs.'
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns the snapshot for SKILL.md aliases without decoding the changed file', async () => {
|
|
68
|
+
await fs.symlink(path.join(directory, 'SKILL.md'), path.join(directory, 'alias.md'));
|
|
69
|
+
const request = await prepare(await skills({ paths: [directory] }));
|
|
70
|
+
await fs.writeFile(path.join(directory, 'SKILL.md'), Buffer.from([0, 255, 128]));
|
|
71
|
+
for (const relative of ['./SKILL.md', 'alias.md']) {
|
|
72
|
+
assert.deepEqual(await request.tools[0].callback({ name: 'writing', path: relative }), {
|
|
73
|
+
name: 'writing', path: relative, content: source
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('runs the complete tool loop without re-rendering skill text or changing instance configuration', async () => {
|
|
79
|
+
const requests = [];
|
|
80
|
+
const provider = new MixCustom();
|
|
81
|
+
provider.create = async request => {
|
|
82
|
+
requests.push(request);
|
|
83
|
+
if (requests.length === 1) return {
|
|
84
|
+
message: '', toolCalls: [{ id: 'skill', name: 'read_skill', input: { name: 'writing' } }]
|
|
85
|
+
};
|
|
86
|
+
if (requests.length === 2) return {
|
|
87
|
+
message: '', toolCalls: [{ id: 'reference', name: 'read_skill', input: { name: 'writing', path: 'references/style.md' } }]
|
|
88
|
+
};
|
|
89
|
+
return { message: '{"answer":"Concrete verbs"}', toolCalls: [] };
|
|
90
|
+
};
|
|
91
|
+
const model = ModelMix.new({ config: { system: 'Be concise.' } })
|
|
92
|
+
.attach('custom', provider).use(await skills({ paths: [directory] })).addText('Use writing.');
|
|
93
|
+
assert.deepEqual(await model.json(), { answer: 'Concrete verbs' });
|
|
94
|
+
assert.equal(requests.length, 3);
|
|
95
|
+
for (const request of requests) {
|
|
96
|
+
assert.equal(request.config.system.split('Available skills:').length, 2);
|
|
97
|
+
assert.equal(request.options.tools[0].function.name, 'read_skill');
|
|
98
|
+
}
|
|
99
|
+
const outputs = requests[2].options.messages.filter(message => message.role === 'tool').map(message => JSON.parse(message.content));
|
|
100
|
+
assert.equal(outputs[0].content, source);
|
|
101
|
+
assert.equal(outputs[1].content, 'Use concrete verbs.');
|
|
102
|
+
assert.equal(model.config.max_history, 0);
|
|
103
|
+
assert.equal(model.config.system, 'Be concise.');
|
|
104
|
+
assert.deepEqual(model.tools, {});
|
|
105
|
+
assert.deepEqual(model.messages, []);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('supports inherited plugins without leaking tools into sibling or later requests', async () => {
|
|
109
|
+
const provider = new MixCustom();
|
|
110
|
+
const requests = [];
|
|
111
|
+
provider.create = async request => {
|
|
112
|
+
requests.push(request);
|
|
113
|
+
return { message: 'ok', toolCalls: [] };
|
|
114
|
+
};
|
|
115
|
+
const model = ModelMix.new().attach('custom', provider).use(await skills({ paths: [directory] }));
|
|
116
|
+
await model.new().addText('child').message();
|
|
117
|
+
await model.addText('first').message();
|
|
118
|
+
await model.addText('second').message();
|
|
119
|
+
await ModelMix.new().attach('custom', provider).addText('sibling').message();
|
|
120
|
+
for (const request of requests.slice(0, 3)) assert.equal(request.options.tools.length, 1);
|
|
121
|
+
assert.equal(requests[3].options.tools, undefined);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('rejects missing files, missing metadata, malformed YAML, and duplicate names', async () => {
|
|
125
|
+
await assert.rejects(skills({ paths: [path.join(temporary, 'missing')] }), /ENOENT/);
|
|
126
|
+
for (const content of ['No frontmatter', '---\nname: writing\n---\nBody', '---\nname: [broken\n---\nBody', '---\nname: writing\nname: duplicate\ndescription: text\n---\nBody']) {
|
|
127
|
+
await fs.writeFile(path.join(directory, 'SKILL.md'), content);
|
|
128
|
+
await assert.rejects(skills({ paths: [directory] }));
|
|
129
|
+
}
|
|
130
|
+
await fs.writeFile(path.join(directory, 'SKILL.md'), source);
|
|
131
|
+
await assert.rejects(skills({ paths: [directory, directory] }), /Duplicate skill name/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('validates configuration and tool arguments', async () => {
|
|
135
|
+
for (const options of [undefined, {}, { paths: [] }, { paths: [''] }, { paths: 'directory' }]) {
|
|
136
|
+
await assert.rejects(skills(options), /paths must be/);
|
|
137
|
+
}
|
|
138
|
+
const request = await prepare(await skills({ paths: [directory] }));
|
|
139
|
+
const read = request.tools[0].callback;
|
|
140
|
+
await assert.rejects(read({ name: 'missing' }), /Unknown skill/);
|
|
141
|
+
await assert.rejects(read({ name: 'writing', extra: true }), /expects a name/);
|
|
142
|
+
for (const value of ['', null, 42, directory]) {
|
|
143
|
+
await assert.rejects(read({ name: 'writing', path: value }), /relative path/);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('blocks traversal, prefix siblings, and symlinks outside the skill directory', async () => {
|
|
148
|
+
await fs.writeFile(path.join(temporary, 'outside.md'), 'outside');
|
|
149
|
+
const sibling = path.join(temporary, 'writing-other');
|
|
150
|
+
await fs.mkdir(sibling);
|
|
151
|
+
await fs.writeFile(path.join(sibling, 'file.md'), 'sibling');
|
|
152
|
+
await fs.symlink(path.join(temporary, 'outside.md'), path.join(directory, 'linked.md'));
|
|
153
|
+
const request = await prepare(await skills({ paths: [directory] }));
|
|
154
|
+
for (const relative of ['../outside.md', '../writing-other/file.md', 'linked.md']) {
|
|
155
|
+
await assert.rejects(request.tools[0].callback({ name: 'writing', path: relative }), /inside the skill directory/);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('blocks SKILL.md symlinks outside the registered directory', async () => {
|
|
160
|
+
await fs.writeFile(path.join(temporary, 'outside.md'), source);
|
|
161
|
+
await fs.unlink(path.join(directory, 'SKILL.md'));
|
|
162
|
+
await fs.symlink(path.join(temporary, 'outside.md'), path.join(directory, 'SKILL.md'));
|
|
163
|
+
await assert.rejects(skills({ paths: [directory] }), /inside the skill directory/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('rejects directories and binary resources', async () => {
|
|
167
|
+
await fs.writeFile(path.join(directory, 'binary'), Buffer.from([0, 255, 128]));
|
|
168
|
+
const request = await prepare(await skills({ paths: [directory] }));
|
|
169
|
+
await assert.rejects(request.tools[0].callback({ name: 'writing', path: 'references' }), /regular file/);
|
|
170
|
+
await assert.rejects(request.tools[0].callback({ name: 'writing', path: 'binary' }));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('propagates cancellation before middleware and resource reads', async () => {
|
|
174
|
+
const plugin = await skills({ paths: [directory] });
|
|
175
|
+
const request = await prepare(plugin);
|
|
176
|
+
const controller = new AbortController();
|
|
177
|
+
const reason = new Error('cancelled');
|
|
178
|
+
controller.abort(reason);
|
|
179
|
+
await assert.rejects(plugin.execute({ request, signal: controller.signal }, () => assert.fail('next called')), error => error === reason);
|
|
180
|
+
await assert.rejects(request.tools[0].callback({ name: 'writing', path: 'references/style.md' }, controller.signal), error => error === reason);
|
|
181
|
+
});
|
|
182
|
+
});
|
package/pnpm-workspace.yaml
CHANGED
package/skills/modelmix/SKILL.md
CHANGED
|
@@ -121,6 +121,27 @@ model.use({
|
|
|
121
121
|
|
|
122
122
|
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.
|
|
123
123
|
|
|
124
|
+
### Loading local skills
|
|
125
|
+
|
|
126
|
+
Use the included skills plugin to expose local `SKILL.md` instructions to a model with tool-call support:
|
|
127
|
+
|
|
128
|
+
```javascript
|
|
129
|
+
import { ModelMix } from 'modelmix';
|
|
130
|
+
import { skills } from 'modelmix/plugins/skills/index.js';
|
|
131
|
+
const model = ModelMix.new()
|
|
132
|
+
.gpt6astra()
|
|
133
|
+
.opus5()
|
|
134
|
+
.use(await skills({ paths: ['./skills/writing'] }))
|
|
135
|
+
.addText('Use the writing skill to revise this paragraph: ...');
|
|
136
|
+
const answer = await model.message();
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Paths identify explicit skill directories or `SKILL.md` files relative to the working directory. YAML `name` and `description` must be non-empty strings. The system prompt receives only the catalog; `read_skill({ name })` loads instructions and `read_skill({ name, path })` reads a supporting UTF-8 file inside that skill directory. Instructions are literal, never EJS-rendered. Skill metadata and instructions are snapshots; recreate the plugin to reload them. References are read on demand. The plugin supplies no script execution or automatic permissions from skill metadata.
|
|
140
|
+
|
|
141
|
+
Plugin tools use `context.request.tools.push({ tool, callback })`. They coexist with local/MCP tools, are scoped to the current execution, and survive tool continuations. Duplicate names fail before a provider call; the skills plugin reserves `read_skill`. Child executions receive the tools only when the plugin is inherited or the tools are explicitly passed to `context.invoke()`.
|
|
142
|
+
|
|
143
|
+
With plugin tools, native `options.tools` entries are combined with registered and plugin tools. OpenAI Responses supports this tool loop, including reasoning returned with function calls.
|
|
144
|
+
|
|
124
145
|
### Unified effort
|
|
125
146
|
|
|
126
147
|
Provider-agnostic reasoning intensity. **Not** an `options` field — use `config.effort` or `.effort(n)`.
|
|
@@ -166,7 +187,7 @@ Use `.effort(n)` (or `config.effort`) to enable Anthropic thinking — e.g. `.ef
|
|
|
166
187
|
`gemini31pro()` `gemini38flash()` `gemini37flash()` `gemini36flash()` `gemini35flash()` `gemini35flashLite()` `gemini31flashLite()`
|
|
167
188
|
|
|
168
189
|
### Grok
|
|
169
|
-
`grok46()` `grok45()` `grok43()` `grok420multiAgent()` `grok420()`
|
|
190
|
+
`grok47()` `grok46()` `grok45()` `grok43()` `grok420multiAgent()` `grok420()`
|
|
170
191
|
|
|
171
192
|
### Perplexity
|
|
172
193
|
`sonar()` `sonarPro()`
|
package/test/grok.test.js
CHANGED
|
@@ -11,6 +11,7 @@ const {
|
|
|
11
11
|
|
|
12
12
|
describe('Grok Model Registration Tests', () => {
|
|
13
13
|
const grokModels = [
|
|
14
|
+
{ method: 'grok47', key: 'grok-4.7' },
|
|
14
15
|
{ method: 'grok46', key: 'grok-4.6' },
|
|
15
16
|
{ method: 'grok45', key: 'grok-4.5' },
|
|
16
17
|
{ method: 'grok43', key: 'grok-4.3' },
|
|
@@ -38,6 +39,38 @@ describe('Grok Model Registration Tests', () => {
|
|
|
38
39
|
expect(model.models[0].provider.config).to.include(config);
|
|
39
40
|
});
|
|
40
41
|
|
|
42
|
+
it('supports Grok 4.7 in chain() and preserves attachment options', () => {
|
|
43
|
+
const options = { temperature: 0.5 };
|
|
44
|
+
const config = { max_history: 3 };
|
|
45
|
+
const model = ModelMix.new().grok47({ options, config });
|
|
46
|
+
|
|
47
|
+
expect(model.models[0].provider).to.be.instanceOf(MixGrok);
|
|
48
|
+
expect(model.models[0].provider.options).to.deep.equal(options);
|
|
49
|
+
expect(model.models[0].provider.config).to.include(config);
|
|
50
|
+
expect(ModelMix.new().chain('grok47').models[0].key).to.equal('grok-4.7');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('sends Grok 4.7 requests to xAI', async () => {
|
|
54
|
+
const api = nock('https://api.x.ai')
|
|
55
|
+
.post('/v1/chat/completions', body => {
|
|
56
|
+
expect(body.model).to.equal('grok-4.7');
|
|
57
|
+
expect(body.temperature).to.equal(0.5);
|
|
58
|
+
return true;
|
|
59
|
+
})
|
|
60
|
+
.reply(200, {
|
|
61
|
+
choices: [{ message: { content: 'ok' } }],
|
|
62
|
+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const response = await ModelMix.new()
|
|
66
|
+
.grok47({ options: { temperature: 0.5 }, config: { apiKey: 'test-key' } })
|
|
67
|
+
.addText('Hi')
|
|
68
|
+
.message();
|
|
69
|
+
|
|
70
|
+
expect(response).to.equal('ok');
|
|
71
|
+
api.done();
|
|
72
|
+
});
|
|
73
|
+
|
|
41
74
|
it('maps unified effort to Grok 4.6 supported levels', () => {
|
|
42
75
|
expect(mapEffort('openai', 0, 'grok-4.6')).to.deep.equal({ reasoning_effort: 'low' });
|
|
43
76
|
expect(mapEffort('openai', 39, 'grok-4.6')).to.deep.equal({ reasoning_effort: 'low' });
|
package/test/plugins.test.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
const { expect } = require('chai');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const
|
|
3
|
+
const nock = require('nock');
|
|
4
|
+
const { MixCustom, MixOpenAIResponses, MixAnthropic, MixGoogle, ModelMix } = require('../index.js');
|
|
5
|
+
const { skills } = require('../plugins/skills');
|
|
4
6
|
|
|
5
7
|
function createProvider(handler = async () => ({ message: 'provider', toolCalls: [] })) {
|
|
6
8
|
const provider = new MixCustom();
|
|
@@ -9,6 +11,153 @@ function createProvider(handler = async () => ({ message: 'provider', toolCalls:
|
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
describe('ModelMix plugins', () => {
|
|
14
|
+
it('runs a native Responses skill loop including reasoning and parallel tool outputs', async () => {
|
|
15
|
+
const requests = [];
|
|
16
|
+
const output = [
|
|
17
|
+
{ type: 'reasoning', id: 'rs_skill', summary: [], encrypted_content: 'opaque-reasoning' },
|
|
18
|
+
{ type: 'message', id: 'msg_skill', role: 'assistant', content: [{ type: 'output_text', text: 'Reading the skill.' }] },
|
|
19
|
+
{ type: 'function_call', id: 'fc_skill', call_id: 'call_skill', name: 'read_skill', arguments: JSON.stringify({ name: 'modelmix' }) },
|
|
20
|
+
{ type: 'function_call', id: 'fc_local', call_id: 'call_local', name: 'local_tool', arguments: '{"value":7}' }
|
|
21
|
+
];
|
|
22
|
+
const scope = nock('https://api.openai.com')
|
|
23
|
+
.post('/v1/responses', body => { requests.push(body); return true; })
|
|
24
|
+
.reply(200, { output })
|
|
25
|
+
.post('/v1/responses', body => { requests.push(body); return true; })
|
|
26
|
+
.reply(200, { output: [{ type: 'message', content: [{ type: 'output_text', text: 'Done.' }] }] });
|
|
27
|
+
try {
|
|
28
|
+
const model = ModelMix.new({ config: { bottleneck: { minTime: 0 } } }).gpt6astra()
|
|
29
|
+
.addTool({ name: 'local_tool', description: 'Local tool', inputSchema: { type: 'object' } }, input => `value=${input.value}`)
|
|
30
|
+
.use(await skills({ paths: [path.join(__dirname, '../skills/modelmix')] }))
|
|
31
|
+
.addText('Use the modelmix skill.');
|
|
32
|
+
expect(await model.message()).to.equal('Done.');
|
|
33
|
+
expect(scope.isDone()).to.equal(true);
|
|
34
|
+
for (const request of requests) {
|
|
35
|
+
expect(request.tools.map(tool => tool.name)).to.have.members(['local_tool', 'read_skill']);
|
|
36
|
+
const skillTool = request.tools.find(tool => tool.name === 'read_skill');
|
|
37
|
+
expect(skillTool.strict).to.equal(false);
|
|
38
|
+
expect(skillTool.parameters.required).to.deep.equal(['name']);
|
|
39
|
+
}
|
|
40
|
+
expect(requests[1].input.slice(2, 6)).to.deep.equal(output);
|
|
41
|
+
const results = requests[1].input.filter(item => item.type === 'function_call_output');
|
|
42
|
+
expect(results.map(item => item.call_id)).to.deep.equal(['call_skill', 'call_local']);
|
|
43
|
+
expect(JSON.parse(results[0].output).content).to.include('name: modelmix');
|
|
44
|
+
expect(results[1].output).to.equal('value=7');
|
|
45
|
+
} finally {
|
|
46
|
+
nock.cleanAll();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('converts neutral tool history and native Responses tool options', () => {
|
|
51
|
+
const request = MixOpenAIResponses.buildResponsesRequest({
|
|
52
|
+
tools: [{ type: 'web_search' }, { type: 'function', function: { name: 'lookup', parameters: { type: 'object' }, strict: true } }],
|
|
53
|
+
tool_choice: { type: 'function', function: { name: 'lookup' } },
|
|
54
|
+
parallel_tool_calls: false,
|
|
55
|
+
messages: [
|
|
56
|
+
{ role: 'assistant', content: 'Checking.', tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'lookup', arguments: '{"query":"test"}' } }] },
|
|
57
|
+
{ role: 'tool', tool_call_id: 'call_1', content: 'Found.' }
|
|
58
|
+
]
|
|
59
|
+
});
|
|
60
|
+
expect(request.tools).to.deep.equal([{ type: 'web_search' }, { type: 'function', name: 'lookup', parameters: { type: 'object' }, strict: true }]);
|
|
61
|
+
expect(request.tool_choice).to.deep.equal({ type: 'function', name: 'lookup' });
|
|
62
|
+
expect(request.parallel_tool_calls).to.equal(false);
|
|
63
|
+
expect(request.input).to.deep.equal([
|
|
64
|
+
{ role: 'assistant', content: [{ type: 'output_text', text: 'Checking.' }] },
|
|
65
|
+
{ type: 'function_call', call_id: 'call_1', name: 'lookup', arguments: '{"query":"test"}' },
|
|
66
|
+
{ type: 'function_call_output', call_id: 'call_1', output: 'Found.' }
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
for (const Provider of [MixCustom, MixAnthropic, MixGoogle]) {
|
|
71
|
+
for (const hasExplicitTool of [false, true]) {
|
|
72
|
+
it(`preserves plugin and registered tools with ${Provider.name} options.tools (${hasExplicitTool ? 'populated' : 'empty'})`, async () => {
|
|
73
|
+
const registered = { name: 'registered', description: 'Registered tool', inputSchema: { type: 'object' } };
|
|
74
|
+
const extra = { ...registered, name: 'explicit' };
|
|
75
|
+
const provider = new Provider();
|
|
76
|
+
const explicitTools = hasExplicitTool ? provider.getOptionsTools({ local: [extra] }).tools : [];
|
|
77
|
+
let received;
|
|
78
|
+
provider.create = async ({ options }) => { received = options.tools; return { message: 'done', toolCalls: [] }; };
|
|
79
|
+
const model = ModelMix.new({ options: { tools: explicitTools } }).attach('custom', provider)
|
|
80
|
+
.addTool(registered, () => 'registered')
|
|
81
|
+
.use(await skills({ paths: [path.join(__dirname, '../skills/modelmix')] })).addText('Use skills');
|
|
82
|
+
await model.message();
|
|
83
|
+
const names = received.flatMap(tool => tool.functionDeclarations || [tool.function || tool]).map(tool => tool.name);
|
|
84
|
+
expect(names).to.have.members(['registered', 'read_skill', ...(hasExplicitTool ? ['explicit'] : [])]);
|
|
85
|
+
expect(model.options.tools).to.deep.equal(explicitTools);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
it('rejects collisions between explicit options and plugin tools before calling the provider', async () => {
|
|
91
|
+
let calls = 0;
|
|
92
|
+
const model = ModelMix.new({ options: { tools: [{ type: 'function', function: { name: 'read_skill' } }] } })
|
|
93
|
+
.attach('custom', createProvider(async () => { calls++; return { message: 'unexpected' }; }))
|
|
94
|
+
.use(await skills({ paths: [path.join(__dirname, '../skills/modelmix')] })).addText('test');
|
|
95
|
+
let failure;
|
|
96
|
+
try { await model.message(); } catch (error) { failure = error; }
|
|
97
|
+
expect(failure?.message).to.include('Duplicate tool name: read_skill');
|
|
98
|
+
expect(calls).to.equal(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('keeps plugin tools request-scoped across tool continuations and alongside local tools', async () => {
|
|
102
|
+
const requests = [];
|
|
103
|
+
const signal = new AbortController().signal;
|
|
104
|
+
const provider = createProvider(async request => {
|
|
105
|
+
requests.push(request);
|
|
106
|
+
if (requests.length === 1) return {
|
|
107
|
+
message: '',
|
|
108
|
+
toolCalls: [
|
|
109
|
+
{ id: 'skill', name: 'read_skill', input: {} },
|
|
110
|
+
{ id: 'local', name: 'local_tool', input: {} }
|
|
111
|
+
]
|
|
112
|
+
};
|
|
113
|
+
return { message: 'done', toolCalls: [] };
|
|
114
|
+
});
|
|
115
|
+
const model = ModelMix.new().attach('custom', provider)
|
|
116
|
+
.addTool({ name: 'local_tool', description: 'Local tool', inputSchema: { type: 'object' } }, () => 'local')
|
|
117
|
+
.use({
|
|
118
|
+
name: 'skills-test',
|
|
119
|
+
async execute(context, next) {
|
|
120
|
+
context.request.tools.push({
|
|
121
|
+
tool: { name: 'read_skill', description: 'Read skill', inputSchema: { type: 'object' } },
|
|
122
|
+
callback: (_args, callbackSignal) => {
|
|
123
|
+
expect(callbackSignal).to.equal(signal);
|
|
124
|
+
return 'skill instructions';
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
return next();
|
|
128
|
+
}
|
|
129
|
+
}).addText('Use both tools');
|
|
130
|
+
|
|
131
|
+
expect(await model.message(signal)).to.equal('done');
|
|
132
|
+
expect(requests).to.have.length(2);
|
|
133
|
+
for (const request of requests) {
|
|
134
|
+
expect(request.options.tools.map(tool => tool.function.name)).to.have.members(['local_tool', 'read_skill']);
|
|
135
|
+
}
|
|
136
|
+
const results = requests[1].options.messages.filter(message => message.role === 'tool');
|
|
137
|
+
expect(results.map(result => result.content)).to.deep.equal(['skill instructions', 'local']);
|
|
138
|
+
expect(model.mcpToolsManager.hasTool('read_skill')).to.equal(false);
|
|
139
|
+
expect(model.tools.local.map(tool => tool.name)).to.deep.equal(['local_tool']);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('rejects collisions between plugin tools and existing tools before calling a provider', async () => {
|
|
143
|
+
let calls = 0;
|
|
144
|
+
const tool = { name: 'same', description: 'Same tool', inputSchema: { type: 'object' } };
|
|
145
|
+
const model = ModelMix.new().attach('custom', createProvider(async () => {
|
|
146
|
+
calls += 1;
|
|
147
|
+
return { message: 'unexpected' };
|
|
148
|
+
})).addTool(tool, () => 'local').use({
|
|
149
|
+
name: 'collision',
|
|
150
|
+
async execute(context, next) {
|
|
151
|
+
context.request.tools.push({ tool, callback: () => 'plugin' });
|
|
152
|
+
return next();
|
|
153
|
+
}
|
|
154
|
+
}).addText('test');
|
|
155
|
+
let failure;
|
|
156
|
+
try { await model.message(); } catch (error) { failure = error; }
|
|
157
|
+
expect(failure?.message).to.include('Duplicate tool name: same');
|
|
158
|
+
expect(calls).to.equal(0);
|
|
159
|
+
});
|
|
160
|
+
|
|
12
161
|
it('keeps registration instance-scoped and lets new instances inherit plugins without history', () => {
|
|
13
162
|
const plugin = { name: 'metrics', execute: (_context, next) => next() };
|
|
14
163
|
const parent = ModelMix.new().use(plugin).addText('parent history');
|