modelmix 5.0.0 → 5.0.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/AGENTS.md +43 -0
- package/README.md +91 -20
- package/demo/demo.js +2 -2
- package/demo/grok.js +2 -2
- package/demo/images.js +2 -2
- package/demo/short.js +2 -2
- package/effort.js +1 -0
- package/index.d.ts +5 -3
- package/index.js +97 -12
- package/package.json +1 -1
- package/skills/modelmix/SKILL.md +40 -7
- package/test/fixtures/dynamic-include.txt +2 -0
- package/test/fixtures/tree.ejs +6 -0
- package/test/grok.test.js +74 -0
- package/test/live.mcp.js +2 -2
- package/test/live.test.js +7 -7
- package/test/templates.test.js +177 -26
package/AGENTS.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Repository Guidelines
|
|
2
|
+
|
|
3
|
+
## Project Overview
|
|
4
|
+
|
|
5
|
+
ModelMix is a CommonJS Node.js library for LLM providers, fallback chains, rate limiting, templates, multimodal requests, and MCP tools. Node is not pinned; the root uses pnpm 11.18 and requires no compilation.
|
|
6
|
+
|
|
7
|
+
## Project Structure & Module Organization
|
|
8
|
+
|
|
9
|
+
- `index.js` owns the public API and provider classes; synchronize public contracts with `index.d.ts`.
|
|
10
|
+
- Root helper modules isolate effort mapping, schemas, HTTP, multipart, and MCP behavior.
|
|
11
|
+
- `test/*.test.js` contains Mocha suites; fixtures and setup live under `test/`.
|
|
12
|
+
- `demo/` holds examples and a separate npm manifest; `skills/modelmix/` contains the published skill.
|
|
13
|
+
- `node_modules/` is generated and ignored. Update `pnpm-lock.yaml` only through pnpm.
|
|
14
|
+
|
|
15
|
+
## Build, Test, and Development Commands
|
|
16
|
+
|
|
17
|
+
- `pnpm install` installs locked root dependencies.
|
|
18
|
+
- `pnpm test` runs the complete Mocha suite with shared setup.
|
|
19
|
+
- `pnpm run test:offline` runs the main mocked regressions without intentional live-provider coverage.
|
|
20
|
+
- `pnpm run test:templates`, `pnpm run test:fallback`, and `pnpm run test:watch` support focused development.
|
|
21
|
+
- `pnpm run test:live` and `pnpm run test:live.mcp` require real credentials, may incur costs, and must be reported separately.
|
|
22
|
+
|
|
23
|
+
No build, lint, formatter, or standalone typecheck command is configured. Do not add tooling or dependencies without approval.
|
|
24
|
+
|
|
25
|
+
## Coding Style & Naming Conventions
|
|
26
|
+
|
|
27
|
+
Use four-space indentation, semicolons, single quotes, CommonJS `require`, `camelCase`, and `PascalCase` classes. Keep provider behavior in `Mix*` classes. Public shortcut changes must update implementation, declarations, tests, docs, demos, and the skill together. Add aliases only with approval.
|
|
28
|
+
|
|
29
|
+
## Testing Guidelines
|
|
30
|
+
|
|
31
|
+
Use Mocha, Chai, Sinon, and Nock. Name regression files `*.test.js`; reproduce bugs before fixing them. Offline tests must not depend on order or real keys. Prefer instance seams such as `_choiceRandom()` over global stubs.
|
|
32
|
+
|
|
33
|
+
## Configuration & Security
|
|
34
|
+
|
|
35
|
+
There is no configuration module: callers pass policy through `ModelMix.new({ config, options })`; credentials come from environment variables. Never commit `.env` files, credentials, secret payloads, or unrequested defaults.
|
|
36
|
+
|
|
37
|
+
## Commit & Pull Request Guidelines
|
|
38
|
+
|
|
39
|
+
History favors short imperative subjects, commonly `feat:`, `fix:`, or `chore:`. Keep commits scoped and do not commit unless requested. Pull requests should explain behavior and compatibility impact, link issues, and list exact test commands and results; include screenshots only for visible changes.
|
|
40
|
+
|
|
41
|
+
## Domain & Contributor Conventions
|
|
42
|
+
|
|
43
|
+
A **provider** is an API backend, a **model shortcut** is a fluent method, and a **fallback chain** is ordered. Write code and docs in English; reply in the contributor's language.
|
package/README.md
CHANGED
|
@@ -70,7 +70,7 @@ const model = await ModelMix.new(setup)
|
|
|
70
70
|
.sonnet5() // (main model) Anthropic claude-sonnet-5
|
|
71
71
|
.gpt56luna() // (fallback 2) OpenAI gpt-5.6-luna
|
|
72
72
|
.gemini36flash({ config: { temperature: 0 } }) // (fallback 3) Google gemini-36-flash
|
|
73
|
-
.
|
|
73
|
+
.grok46() // (fallback 4) Grok grok-4.6
|
|
74
74
|
.addText("What's your name?");
|
|
75
75
|
|
|
76
76
|
console.log(await model.message());
|
|
@@ -124,7 +124,7 @@ ModelMix.new().effort(-1).minimaxM3().addText('...').message();
|
|
|
124
124
|
| DeepSeek V4 | off | `low`↑ | `high`↑ | `high`↑ | `max`↑ | — |
|
|
125
125
|
| MiniMax M3 | off | adaptive | adaptive | adaptive | adaptive | adaptive |
|
|
126
126
|
|
|
127
|
-
\* 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 maps adaptive thinking + `output_config.effort` on Claude 5 / Fable / Opus 4.6+ / Sonnet 4.6+; older models (Sonnet 4.5, Haiku 4.5) get `thinking.type=enabled` + `budget_tokens`. `-1` = provider adaptive/dynamic when available, else no-op. Levels clamp to what each model supports.
|
|
127
|
+
\* 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 maps adaptive thinking + `output_config.effort` on Claude 5 / Fable / Opus 4.6+ / Sonnet 4.6+; older models (Sonnet 4.5, Haiku 4.5) get `thinking.type=enabled` + `budget_tokens`. Grok 4.6 clamps 0–39 / 40–59 / 60–79 / 80–100 to `low` / `medium` / `high` / `xhigh`; without effort it uses the native `high` default. `-1` = provider adaptive/dynamic when available, else no-op. Levels clamp to what each model supports.
|
|
128
128
|
|
|
129
129
|
Migration: former `*think()` shorthands are removed — use `.effort(n).<model>()` (or any 0–100 / `-1`). Kimi: `kimiK25()` / `kimiK26()`. Grok 4.20: `.grok420()` is non-reasoning; `.effort(20+).grok420()` (or `-1`) selects the reasoning model.
|
|
130
130
|
|
|
@@ -192,6 +192,7 @@ Here's a comprehensive list of available methods:
|
|
|
192
192
|
| `gemini35flash()` | Google | gemini-3.5-flash | [\$0.75/\$4.50][3] |
|
|
193
193
|
| `gemini35flashLite()`| Google | gemini-3.5-flash-lite | [\$0.30/\$2.50][3] |
|
|
194
194
|
| `gemini31flashLite()`| Google | gemini-3.1-flash-lite-preview | [\$0.25/\$1.50][3] |
|
|
195
|
+
| `grok46()` | Grok | grok-4.6 | [\$2.00/\$6.00][6] |
|
|
195
196
|
| `grok45()` | Grok | grok-4.5 | [\$2.00/\$6.00][6] |
|
|
196
197
|
| `grok43()` | Grok | grok-4.3 | [\$1.25/\$2.50][6] |
|
|
197
198
|
| `grok420multiAgent()`| Grok | grok-4.20-multi-agent-0309 | [\$1.25/\$2.50][6] |
|
|
@@ -249,20 +250,27 @@ Templates are executable JavaScript and must be controlled by the developer. Pas
|
|
|
249
250
|
| --- | --- |
|
|
250
251
|
| `setSystemFromFile(path)` | Load the system prompt from a file |
|
|
251
252
|
| `addTextFromFile(path)` | Load a user message from a file |
|
|
252
|
-
| `
|
|
253
|
-
| `
|
|
253
|
+
| `assign({ key: value })` | Assign EJS template data |
|
|
254
|
+
| `assignKey(key, value)` | Assign one EJS template-data value |
|
|
255
|
+
| `assignKeyFromFile(key, path)` | Assign an EJS-rendered file to one template-data key |
|
|
254
256
|
|
|
255
|
-
### Basic example with `
|
|
257
|
+
### Basic example with `assign`
|
|
256
258
|
|
|
257
259
|
```javascript
|
|
258
260
|
const gpt = ModelMix.new().gpt52();
|
|
259
261
|
|
|
260
262
|
gpt.addText('Write a short story about a <%- animal %> that lives in <%- place %>.');
|
|
261
|
-
gpt.
|
|
263
|
+
gpt.assign({ animal: 'cat', place: 'a haunted castle' });
|
|
262
264
|
|
|
263
265
|
console.log(await gpt.message());
|
|
264
266
|
```
|
|
265
267
|
|
|
268
|
+
Use `assignKey()` when assigning a single value:
|
|
269
|
+
|
|
270
|
+
```javascript
|
|
271
|
+
gpt.assignKey('animal', 'cat');
|
|
272
|
+
```
|
|
273
|
+
|
|
266
274
|
### Loading prompts from `.md` files
|
|
267
275
|
|
|
268
276
|
Instead of writing long prompts inline, keep them in separate Markdown files. This makes them easier to read, edit, and version control.
|
|
@@ -287,7 +295,7 @@ const gpt = ModelMix.new().gpt56luna();
|
|
|
287
295
|
gpt.setSystemFromFile('./prompts/system.md');
|
|
288
296
|
gpt.addTextFromFile('./prompts/task.md');
|
|
289
297
|
|
|
290
|
-
gpt.
|
|
298
|
+
gpt.assign({
|
|
291
299
|
role: 'a senior analyst',
|
|
292
300
|
topic: 'market trends',
|
|
293
301
|
language: 'Spanish',
|
|
@@ -297,15 +305,38 @@ gpt.replace({
|
|
|
297
305
|
console.log(await gpt.message());
|
|
298
306
|
```
|
|
299
307
|
|
|
300
|
-
###
|
|
308
|
+
### Simple includes
|
|
301
309
|
|
|
302
|
-
Use `
|
|
310
|
+
Use EJS `include` to compose a prompt from other files. Include paths are resolved relative to the template containing them.
|
|
311
|
+
|
|
312
|
+
```ejs
|
|
313
|
+
<%- include('shared/rules.md') %>
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
For example:
|
|
317
|
+
|
|
318
|
+
**`prompts/task.md`**
|
|
319
|
+
```markdown
|
|
320
|
+
Analyze the request following these rules:
|
|
321
|
+
|
|
322
|
+
<%- include('shared/rules.md') %>
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
**`prompts/shared/rules.md`**
|
|
326
|
+
```markdown
|
|
327
|
+
- Be concise
|
|
328
|
+
- Explain assumptions
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### Dynamic includes
|
|
332
|
+
|
|
333
|
+
When the file changes at runtime, pass its path as template data and call `include` with that variable. This replaces the file-injection use case while keeping composition inside the template.
|
|
303
334
|
|
|
304
335
|
**`prompts/summarize.md`**
|
|
305
336
|
```markdown
|
|
306
337
|
Summarize the following article in 3 bullet points:
|
|
307
338
|
|
|
308
|
-
<%-
|
|
339
|
+
<%- include(articleFile) %>
|
|
309
340
|
```
|
|
310
341
|
|
|
311
342
|
**`app.js`**
|
|
@@ -313,11 +344,29 @@ Summarize the following article in 3 bullet points:
|
|
|
313
344
|
const gpt = ModelMix.new().gpt5mini();
|
|
314
345
|
|
|
315
346
|
gpt.addTextFromFile('./prompts/summarize.md');
|
|
316
|
-
gpt.
|
|
347
|
+
gpt.assign({ articleFile: '../data/article.md' });
|
|
317
348
|
|
|
318
349
|
console.log(await gpt.message());
|
|
319
350
|
```
|
|
320
351
|
|
|
352
|
+
Static and dynamic include paths are resolved relative to the containing template. Included files are EJS template source, so both the path and file must be controlled by the developer. Pass untrusted runtime content through ordinary `assign()` values instead of using it as an include path.
|
|
353
|
+
|
|
354
|
+
### Assigning a rendered file to a key
|
|
355
|
+
|
|
356
|
+
Use `assignKeyFromFile()` when the outer template needs the rendered contents of a file as one data value:
|
|
357
|
+
|
|
358
|
+
```javascript
|
|
359
|
+
const gpt = ModelMix.new().gpt5mini();
|
|
360
|
+
|
|
361
|
+
gpt.assign({ language: 'Spanish' });
|
|
362
|
+
gpt.assignKeyFromFile('rules', './prompts/rules.md');
|
|
363
|
+
gpt.addText('Follow these rules:\n<%- rules %>');
|
|
364
|
+
|
|
365
|
+
console.log(await gpt.message());
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
`assignKeyFromFile()` uses EJS `include` internally. The assigned file can access ordinary `assign()` data and use includes relative to its own path. It is rendered once per request and reused across the system prompt and messages in that request. The file is template source and must be developer-controlled.
|
|
369
|
+
|
|
321
370
|
### Full template workflow
|
|
322
371
|
|
|
323
372
|
Combine all methods to build reusable, file-based prompt pipelines:
|
|
@@ -325,16 +374,21 @@ Combine all methods to build reusable, file-based prompt pipelines:
|
|
|
325
374
|
**`prompts/system.md`**
|
|
326
375
|
```markdown
|
|
327
376
|
You are <%- role %>. Follow these rules:
|
|
377
|
+
<%- include('partials/rules.md') %>
|
|
378
|
+
- Respond in <%- language %>
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
**`prompts/partials/rules.md`**
|
|
382
|
+
```markdown
|
|
328
383
|
- Be concise
|
|
329
384
|
- Use examples when possible
|
|
330
|
-
- Respond in <%- language %>
|
|
331
385
|
```
|
|
332
386
|
|
|
333
387
|
**`prompts/review.md`**
|
|
334
388
|
```markdown
|
|
335
389
|
Review the following code and suggest improvements:
|
|
336
390
|
|
|
337
|
-
<%-
|
|
391
|
+
<%- include('../src/utils.js') %>
|
|
338
392
|
```
|
|
339
393
|
|
|
340
394
|
**`app.js`**
|
|
@@ -344,8 +398,7 @@ const gpt = ModelMix.new().gpt5mini();
|
|
|
344
398
|
gpt.setSystemFromFile('./prompts/system.md');
|
|
345
399
|
gpt.addTextFromFile('./prompts/review.md');
|
|
346
400
|
|
|
347
|
-
gpt.
|
|
348
|
-
gpt.replaceKeyFromFile('code', './src/utils.js');
|
|
401
|
+
gpt.assign({ role: 'a senior code reviewer', language: 'English' });
|
|
349
402
|
|
|
350
403
|
console.log(await gpt.message());
|
|
351
404
|
```
|
|
@@ -393,13 +446,30 @@ Do not use emojis.
|
|
|
393
446
|
|
|
394
447
|
Weights are relative and do not need to total 100. A block must either give every option a weight or omit all weights. Directives must be on their own lines; choices can be nested and can also appear inside relative includes. Each new request makes a new selection, while retries, provider fallbacks, and tool continuations keep the original selection.
|
|
395
448
|
|
|
396
|
-
|
|
449
|
+
### Recursive includes
|
|
450
|
+
|
|
451
|
+
An included template can include itself to render recursive data. Always define a stopping condition:
|
|
397
452
|
|
|
398
453
|
```ejs
|
|
399
|
-
<%-
|
|
454
|
+
<%- node.text %>
|
|
455
|
+
|
|
456
|
+
<% if (node.children?.length && depth < maxDepth) { %>
|
|
457
|
+
<% for (const child of node.children) { %>
|
|
458
|
+
<%- include('tree.ejs', { node: child, depth: depth + 1, maxDepth }) %>
|
|
459
|
+
<% } %>
|
|
460
|
+
<% } %>
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
```javascript
|
|
464
|
+
const gpt = ModelMix.new().gpt5mini();
|
|
465
|
+
|
|
466
|
+
gpt.addTextFromFile('./prompts/tree.ejs');
|
|
467
|
+
gpt.assign({ node: promptTree, depth: 0, maxDepth: 10 });
|
|
468
|
+
|
|
469
|
+
console.log(await gpt.message());
|
|
400
470
|
```
|
|
401
471
|
|
|
402
|
-
Content supplied through `
|
|
472
|
+
Content supplied through `assign()` remains data. EJS tags inside that content are not executed recursively; use `assignKeyFromFile()` only for developer-controlled EJS files that should be rendered.
|
|
403
473
|
|
|
404
474
|
## 🧩 JSON Structured Output
|
|
405
475
|
|
|
@@ -800,8 +870,9 @@ new ModelMix(args = { options: {}, config: {} })
|
|
|
800
870
|
- `addTextFromFile(filePath, config = { role: "user", cache? })`: Adds a text message from a file.
|
|
801
871
|
- `addImage(filePath, config = { role: "user", cache? })`: Adds an image message from a file path.
|
|
802
872
|
- `addImageFromUrl(url, config = { role: "user", cache? })`: Adds an image message from URL.
|
|
803
|
-
- `
|
|
804
|
-
- `
|
|
873
|
+
- `assign(keyValues)`: Assigns EJS data for messages and system prompts.
|
|
874
|
+
- `assignKey(key, value)`: Assigns one EJS data value.
|
|
875
|
+
- `assignKeyFromFile(key, filePath)`: Renders an EJS file through `include` and assigns its output to one key.
|
|
805
876
|
- `message()`: Sends the message and returns the response.
|
|
806
877
|
- `raw()`: Sends the message and returns the complete response data including:
|
|
807
878
|
- `message`: The text response from the model
|
package/demo/demo.js
CHANGED
|
@@ -22,12 +22,12 @@ const pplxSettings = {
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
mmix.
|
|
25
|
+
mmix.assign({ name: 'ALF' });
|
|
26
26
|
|
|
27
27
|
console.log("\n" + '--------| gpt51() |--------');
|
|
28
28
|
const gptArgs = { options: { reasoning_effort: "none", verbosity: "low" } };
|
|
29
29
|
const gpt = mmix.gpt51(gptArgs).addText("Have you ever eaten a <%- animal %>?");
|
|
30
|
-
gpt.
|
|
30
|
+
gpt.assignKey('animal', 'cat');
|
|
31
31
|
await gpt.json({ time: '24:00:00', message: 'Hello' }, { time: 'Time in format HH:MM:SS' });
|
|
32
32
|
|
|
33
33
|
console.log("\n" + '--------| sonnet45() |--------');
|
package/demo/grok.js
CHANGED
package/demo/images.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { ModelMix } from '../index.js';
|
|
2
2
|
try { process.loadEnvFile(); } catch {}
|
|
3
3
|
|
|
4
|
-
const model = ModelMix.new({ config: { max_history: 2, debug: 2 } }).
|
|
4
|
+
const model = ModelMix.new({ config: { max_history: 2, debug: 2 } }).grok46()
|
|
5
5
|
// model.addImageFromUrl('https://pbs.twimg.com/media/F6-GsjraAAADDGy?format=jpg');
|
|
6
6
|
model.addImage('./img.png');
|
|
7
7
|
model.addText('in one word, which is the main color of the image?');
|
|
8
8
|
|
|
9
|
-
console.log(await model.json({ color: "string" }));
|
|
9
|
+
console.log(await model.json({ color: "string" }));
|
package/demo/short.js
CHANGED
|
@@ -13,7 +13,7 @@ const mmix = await ModelMix.new(setup)
|
|
|
13
13
|
.gpt56luna() // (fallback 1) OpenAI gpt-5.6-luna
|
|
14
14
|
.gemini36flash({ config: { temperature: 0 } }) // (fallback 2) Google gemini-3.6-flash
|
|
15
15
|
.gpt41nano() // (fallback 3) OpenAI gpt-4.1-nano
|
|
16
|
-
.
|
|
16
|
+
.grok46() // (fallback 4) Grok grok-4.6
|
|
17
17
|
.addText("What's your name?");
|
|
18
18
|
|
|
19
|
-
console.log(await mmix.message());
|
|
19
|
+
console.log(await mmix.message());
|
package/effort.js
CHANGED
|
@@ -35,6 +35,7 @@ const GEMINI_BANDS = [
|
|
|
35
35
|
|
|
36
36
|
/** Exact model → supported OpenAI reasoning_effort values */
|
|
37
37
|
const OPENAI_MODEL_LEVELS = {
|
|
38
|
+
'grok-4.6': ['low', 'medium', 'high', 'xhigh'],
|
|
38
39
|
'gpt-5': ['minimal', 'low', 'medium', 'high'],
|
|
39
40
|
'gpt-5-mini': ['minimal', 'low', 'medium', 'high'],
|
|
40
41
|
'gpt-5-nano': ['minimal', 'low', 'medium', 'high'],
|
package/index.d.ts
CHANGED
|
@@ -70,7 +70,7 @@ export interface ModelMixConfig {
|
|
|
70
70
|
roundRobin?: boolean;
|
|
71
71
|
/** Unified effort (-1 adaptive, or 0–100). Not a native provider field. */
|
|
72
72
|
effort?: EffortValue | null;
|
|
73
|
-
|
|
73
|
+
templateData?: Record<string, unknown>;
|
|
74
74
|
schema?: Record<string, unknown>;
|
|
75
75
|
[key: string]: unknown;
|
|
76
76
|
}
|
|
@@ -347,7 +347,8 @@ export declare class ModelMix {
|
|
|
347
347
|
static hasToolInteraction(message: ChatMessage | null | undefined): boolean;
|
|
348
348
|
|
|
349
349
|
new(setup?: ModelMixSetup): ModelMix;
|
|
350
|
-
|
|
350
|
+
assign(keyValues: Record<string, unknown>): this;
|
|
351
|
+
assignKey(key: string, value: unknown): this;
|
|
351
352
|
effort(value: EffortValue): this;
|
|
352
353
|
attach(key: string, provider: MixCustom): this;
|
|
353
354
|
|
|
@@ -405,6 +406,7 @@ export declare class ModelMix {
|
|
|
405
406
|
sonar(args?: ModelAttachArgs): this;
|
|
406
407
|
|
|
407
408
|
// Grok
|
|
409
|
+
grok46(args?: ModelAttachArgs): this;
|
|
408
410
|
grok45(args?: ModelAttachArgs): this;
|
|
409
411
|
grok43(args?: ModelAttachArgs): this;
|
|
410
412
|
grok420multiAgent(args?: ModelAttachArgs): this;
|
|
@@ -451,7 +453,7 @@ export declare class ModelMix {
|
|
|
451
453
|
raw(): Promise<ModelMixResult>;
|
|
452
454
|
stream(callback: StreamCallback): Promise<ModelMixResult>;
|
|
453
455
|
|
|
454
|
-
|
|
456
|
+
assignKeyFromFile(key: string, filePath: string): this;
|
|
455
457
|
groupByRoles(messages: ChatMessage[]): ChatMessage[];
|
|
456
458
|
prepareMessages(): Promise<void>;
|
|
457
459
|
readFile(filePath: string, options?: { encoding?: BufferEncoding | null }): string | Buffer;
|
package/index.js
CHANGED
|
@@ -79,6 +79,15 @@ function validateTemplateData(value) {
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
function validateTemplateDataKey(key) {
|
|
83
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
84
|
+
throw new TypeError('Template data key must be a non-empty string.');
|
|
85
|
+
}
|
|
86
|
+
if (key === '$mix') {
|
|
87
|
+
throw new TypeError('Template data key "$mix" is reserved.');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
82
91
|
function templateLocation({ filename, label }, lineNumber) {
|
|
83
92
|
return `${filename || label} at line ${lineNumber}`;
|
|
84
93
|
}
|
|
@@ -214,6 +223,7 @@ function createTemplateRenderContext(random = Math.random) {
|
|
|
214
223
|
|
|
215
224
|
return {
|
|
216
225
|
helpers: Object.freeze({ choice }),
|
|
226
|
+
renderedTemplateData: new Map(),
|
|
217
227
|
renderedMessages: new Map(),
|
|
218
228
|
renderedSystems: new Map()
|
|
219
229
|
};
|
|
@@ -249,6 +259,21 @@ const GPT56_LONG_CONTEXT_PRICING = Object.freeze({
|
|
|
249
259
|
outputMultiplier: 1.5
|
|
250
260
|
});
|
|
251
261
|
|
|
262
|
+
const GROK46_LONG_CONTEXT_PRICING = Object.freeze({
|
|
263
|
+
inputThreshold: 200_000,
|
|
264
|
+
inputMultiplier: 2,
|
|
265
|
+
outputMultiplier: 2,
|
|
266
|
+
inclusive: true
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
function usesLongContextRates(pricing, inputTokens) {
|
|
270
|
+
const longContext = pricing.longContext;
|
|
271
|
+
if (!longContext) return false;
|
|
272
|
+
return longContext.inclusive
|
|
273
|
+
? inputTokens >= longContext.inputThreshold
|
|
274
|
+
: inputTokens > longContext.inputThreshold;
|
|
275
|
+
}
|
|
276
|
+
|
|
252
277
|
const MODEL_PRICING = {
|
|
253
278
|
// OpenAI
|
|
254
279
|
'gpt-realtime-mini': { input: 0.60, cachedInput: 0.06, output: 2.40 },
|
|
@@ -297,6 +322,7 @@ const MODEL_PRICING = {
|
|
|
297
322
|
'gemini-2.5-flash': { input: 0.30, output: 2.50 },
|
|
298
323
|
'gemini-3.1-flash-lite-preview': { input: 0.25, output: 1.50 },
|
|
299
324
|
// Grok
|
|
325
|
+
'grok-4.6': { input: 2.00, cachedInput: 0.50, output: 6.00, longContext: GROK46_LONG_CONTEXT_PRICING },
|
|
300
326
|
'grok-4.5': { input: 2.00, output: 6.00 },
|
|
301
327
|
'grok-4.3': { input: 1.25, output: 2.50 },
|
|
302
328
|
'grok-4.20-multi-agent-0309': { input: 1.25, output: 2.50 },
|
|
@@ -354,6 +380,7 @@ class ModelMix {
|
|
|
354
380
|
this.toolClient = {};
|
|
355
381
|
this.mcp = {};
|
|
356
382
|
this.mcpToolsManager = new MCPToolsManager();
|
|
383
|
+
this.templateFileAssignments = new Map();
|
|
357
384
|
this.messageTemplates = new WeakMap();
|
|
358
385
|
this.lastRaw = null;
|
|
359
386
|
this.options = {
|
|
@@ -387,8 +414,8 @@ class ModelMix {
|
|
|
387
414
|
source: this.config.system,
|
|
388
415
|
filename: null
|
|
389
416
|
};
|
|
390
|
-
if (this.config.
|
|
391
|
-
validateTemplateData(this.config.
|
|
417
|
+
if (this.config.templateData !== undefined) {
|
|
418
|
+
validateTemplateData(this.config.templateData);
|
|
392
419
|
}
|
|
393
420
|
// Unified effort is ModelMix policy (config.effort / .effort()), not a native option.
|
|
394
421
|
if (this.config.effort !== undefined && this.config.effort !== null) {
|
|
@@ -401,12 +428,20 @@ class ModelMix {
|
|
|
401
428
|
|
|
402
429
|
}
|
|
403
430
|
|
|
404
|
-
|
|
431
|
+
assign(keyValues) {
|
|
405
432
|
validateTemplateData(keyValues);
|
|
406
|
-
|
|
433
|
+
for (const key of Object.keys(keyValues)) {
|
|
434
|
+
this.templateFileAssignments.delete(key);
|
|
435
|
+
}
|
|
436
|
+
this.config.templateData = { ...this.config.templateData, ...keyValues };
|
|
407
437
|
return this;
|
|
408
438
|
}
|
|
409
439
|
|
|
440
|
+
assignKey(key, value) {
|
|
441
|
+
validateTemplateDataKey(key);
|
|
442
|
+
return this.assign({ [key]: value });
|
|
443
|
+
}
|
|
444
|
+
|
|
410
445
|
/**
|
|
411
446
|
* Set unified reasoning effort: -1 (adaptive) or 0..100.
|
|
412
447
|
* Stored in config.effort; mapped to provider-native fields at request time
|
|
@@ -431,6 +466,10 @@ class ModelMix {
|
|
|
431
466
|
if (!hasSystemOverride) {
|
|
432
467
|
instance.systemTemplate = { ...this.systemTemplate };
|
|
433
468
|
}
|
|
469
|
+
instance.templateFileAssignments = new Map(this.templateFileAssignments);
|
|
470
|
+
for (const key of Object.keys(config.templateData || {})) {
|
|
471
|
+
instance.templateFileAssignments.delete(key);
|
|
472
|
+
}
|
|
434
473
|
instance.models = this.models; // Share models array for round-robin rotation
|
|
435
474
|
return instance;
|
|
436
475
|
}
|
|
@@ -512,7 +551,7 @@ class ModelMix {
|
|
|
512
551
|
|
|
513
552
|
const normalized = ModelMix.normalizeTokenUsage(tokens);
|
|
514
553
|
const longContext = pricing.longContext;
|
|
515
|
-
const useLongContextRates =
|
|
554
|
+
const useLongContextRates = usesLongContextRates(pricing, normalized.input);
|
|
516
555
|
const inputMultiplier = useLongContextRates ? longContext.inputMultiplier : 1;
|
|
517
556
|
const outputMultiplier = useLongContextRates ? longContext.outputMultiplier : 1;
|
|
518
557
|
const {
|
|
@@ -564,9 +603,8 @@ class ModelMix {
|
|
|
564
603
|
|
|
565
604
|
const normalized = ModelMix.normalizeTokenUsage(tokens);
|
|
566
605
|
const longContext = pricing.longContext;
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
: 1;
|
|
606
|
+
const useLongContextRates = usesLongContextRates(pricing, normalized.input);
|
|
607
|
+
const inputMultiplier = useLongContextRates ? longContext.inputMultiplier : 1;
|
|
570
608
|
const cachedInputPerMillion = pricing.cachedInput ?? pricing.input;
|
|
571
609
|
const cacheWritePerMillion = pricing.cacheWrite ?? pricing.input;
|
|
572
610
|
const cacheWrite1hPerMillion = pricing.cacheWrite1h ?? cacheWritePerMillion;
|
|
@@ -821,6 +859,9 @@ class ModelMix {
|
|
|
821
859
|
return this.attach('sonar', new MixPerplexity({ options, config }));
|
|
822
860
|
}
|
|
823
861
|
|
|
862
|
+
grok46({ options = {}, config = {} } = {}) {
|
|
863
|
+
return this.attach('grok-4.6', new MixGrok({ options, config }));
|
|
864
|
+
}
|
|
824
865
|
grok45({ options = {}, config = {} } = {}) {
|
|
825
866
|
return this.attach('grok-4.5', new MixGrok({ options, config }));
|
|
826
867
|
}
|
|
@@ -1217,27 +1258,71 @@ class ModelMix {
|
|
|
1217
1258
|
return this.execute({ options: { stream: true } });
|
|
1218
1259
|
}
|
|
1219
1260
|
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1261
|
+
assignKeyFromFile(key, filePath) {
|
|
1262
|
+
validateTemplateDataKey(key);
|
|
1263
|
+
this.readFile(filePath);
|
|
1264
|
+
|
|
1265
|
+
const templateData = { ...this.config.templateData };
|
|
1266
|
+
delete templateData[key];
|
|
1267
|
+
this.config.templateData = templateData;
|
|
1268
|
+
this.templateFileAssignments.set(key, Object.freeze({
|
|
1269
|
+
key,
|
|
1270
|
+
filename: path.resolve(filePath)
|
|
1271
|
+
}));
|
|
1272
|
+
return this;
|
|
1223
1273
|
}
|
|
1224
1274
|
|
|
1225
1275
|
_choiceRandom() {
|
|
1226
1276
|
return Math.random();
|
|
1227
1277
|
}
|
|
1228
1278
|
|
|
1279
|
+
_templateData(renderContext) {
|
|
1280
|
+
const assigned = { ...(this.config.templateData || {}), $mix: renderContext.helpers };
|
|
1281
|
+
const data = { ...assigned };
|
|
1282
|
+
|
|
1283
|
+
for (const [key, assignment] of this.templateFileAssignments) {
|
|
1284
|
+
data[key] = this._renderAssignedTemplate(assignment, assigned, renderContext);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
return data;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
_renderAssignedTemplate(assignment, data, renderContext) {
|
|
1291
|
+
if (renderContext.renderedTemplateData.has(assignment)) {
|
|
1292
|
+
return renderContext.renderedTemplateData.get(assignment);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
const rendered = this._renderTemplateWithData(
|
|
1296
|
+
`<%- include(${JSON.stringify(assignment.filename)}) %>`,
|
|
1297
|
+
{
|
|
1298
|
+
filename: assignment.filename,
|
|
1299
|
+
label: `template data "${assignment.key}"`
|
|
1300
|
+
},
|
|
1301
|
+
data
|
|
1302
|
+
);
|
|
1303
|
+
renderContext.renderedTemplateData.set(assignment, rendered);
|
|
1304
|
+
return rendered;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1229
1307
|
_renderTemplate(
|
|
1230
1308
|
source,
|
|
1231
1309
|
{ filename = null, label = 'template' } = {},
|
|
1232
1310
|
renderContext = createTemplateRenderContext(() => this._choiceRandom())
|
|
1233
1311
|
) {
|
|
1312
|
+
return this._renderTemplateWithData(
|
|
1313
|
+
source,
|
|
1314
|
+
{ filename, label },
|
|
1315
|
+
this._templateData(renderContext)
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
_renderTemplateWithData(source, { filename = null, label = 'template' }, data) {
|
|
1234
1320
|
if (typeof source !== 'string') {
|
|
1235
1321
|
throw new TypeError(`${label} source must be a string.`);
|
|
1236
1322
|
}
|
|
1237
1323
|
|
|
1238
1324
|
try {
|
|
1239
1325
|
const template = preprocessChoiceDirectives(source, { filename, label });
|
|
1240
|
-
const data = { ...(this.config.replace || {}), $mix: renderContext.helpers };
|
|
1241
1326
|
return ejs.render(template, data, {
|
|
1242
1327
|
...(filename && { filename }),
|
|
1243
1328
|
async: false,
|
package/package.json
CHANGED
package/skills/modelmix/SKILL.md
CHANGED
|
@@ -114,7 +114,7 @@ ModelMix.new({ config: { effort: 80 } })
|
|
|
114
114
|
| DeepSeek V4 | off | `low`↑ | `high`↑ | `high`↑ | `max`↑ | — |
|
|
115
115
|
| MiniMax M3 | off | adaptive | adaptive | adaptive | adaptive | adaptive |
|
|
116
116
|
|
|
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`. `-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.
|
|
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.
|
|
118
118
|
|
|
119
119
|
## Available Model Shorthands
|
|
120
120
|
|
|
@@ -130,7 +130,7 @@ Use `.effort(n)` (or `config.effort`) to enable Anthropic thinking — e.g. `.ef
|
|
|
130
130
|
`gemini3pro()` `gemini3flash()` `gemini36flash()` `gemini35flash()` `gemini35flashLite()` `gemini31flashLite()` `gemini25pro()` `gemini25flash()`
|
|
131
131
|
|
|
132
132
|
### Grok
|
|
133
|
-
`grok45()` `grok43()` `grok420multiAgent()` `grok420()`
|
|
133
|
+
`grok46()` `grok45()` `grok43()` `grok420multiAgent()` `grok420()`
|
|
134
134
|
|
|
135
135
|
### Perplexity
|
|
136
136
|
`sonar()` `sonarPro()`
|
|
@@ -365,15 +365,47 @@ All image methods accept an optional second argument `{ role }` (default `"user"
|
|
|
365
365
|
const model = ModelMix.new().gpt5mini();
|
|
366
366
|
model.setSystemFromFile('./prompts/system.md');
|
|
367
367
|
model.addTextFromFile('./prompts/task.md');
|
|
368
|
-
model.
|
|
368
|
+
model.assign({
|
|
369
369
|
role: 'data analyst',
|
|
370
370
|
language: 'Spanish'
|
|
371
371
|
});
|
|
372
|
-
model.replaceKeyFromFile('code', './src/utils.js');
|
|
373
372
|
console.log(await model.message());
|
|
374
373
|
```
|
|
375
374
|
|
|
376
|
-
Templates use standard EJS syntax. Use `<%- value %>` for raw prompt content and `<%= value %>` only when XML escaping is intentional. Missing variables and files throw. Templates may contain JavaScript, so the template source must be developer-controlled; untrusted content belongs only in `
|
|
375
|
+
Templates use standard EJS syntax. Use `<%- value %>` for raw prompt content and `<%= value %>` only when XML escaping is intentional. Missing variables and files throw. Templates may contain JavaScript, so the template source must be developer-controlled; untrusted content belongs only in `assign()` data.
|
|
376
|
+
|
|
377
|
+
Use `assignKey(key, value)` for one value and `assign({ ... })` for several values.
|
|
378
|
+
|
|
379
|
+
Start with a static include. Paths are resolved relative to the containing template:
|
|
380
|
+
|
|
381
|
+
```ejs
|
|
382
|
+
<%- include('shared/rules.md') %>
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
Use a variable when the included file must be selected dynamically:
|
|
386
|
+
|
|
387
|
+
```ejs
|
|
388
|
+
Analyze the following source:
|
|
389
|
+
|
|
390
|
+
<%- include(sourceFile) %>
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
```javascript
|
|
394
|
+
model.assign({ sourceFile: '../src/utils.js' });
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Included files are EJS source, so the path and file must be developer-controlled. Untrusted runtime content belongs in ordinary `assign()` values, not include paths. To expose a rendered file as a data key, call `assignKeyFromFile(key, filePath)`; it uses EJS `include`, supports includes relative to that file, and renders once per request. For recursive data, a template may include itself with an explicit stopping condition:
|
|
398
|
+
|
|
399
|
+
```ejs
|
|
400
|
+
<%- node.text %>
|
|
401
|
+
<% if (node.children?.length && depth < maxDepth) { %>
|
|
402
|
+
<% for (const child of node.children) { %>
|
|
403
|
+
<%- include('tree.ejs', { node: child, depth: depth + 1, maxDepth }) %>
|
|
404
|
+
<% } %>
|
|
405
|
+
<% } %>
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Initialize it with `assign({ node, depth: 0, maxDepth: 10 })`. Values supplied through `assign()` remain data and are never interpreted recursively as EJS.
|
|
377
409
|
|
|
378
410
|
Use ModelMix choice directives for random prompt variants:
|
|
379
411
|
|
|
@@ -540,8 +572,9 @@ const model = ModelMix.new({
|
|
|
540
572
|
| `.addImage(path, {role?, cache?})` | `this` | Add image from file |
|
|
541
573
|
| `.addImageFromUrl(url, {role?, cache?})` | `this` | Add image from URL or data URI |
|
|
542
574
|
| `.addImageFromBuffer(buffer, {role?, cache?})` | `this` | Add image from Buffer |
|
|
543
|
-
| `.
|
|
544
|
-
| `.
|
|
575
|
+
| `.assign({})` | `this` | Assign EJS template data |
|
|
576
|
+
| `.assignKey(key, value)` | `this` | Assign one EJS template-data value |
|
|
577
|
+
| `.assignKeyFromFile(key, path)` | `this` | Assign the rendered output of an EJS file to one key |
|
|
545
578
|
| `.message()` | `Promise<string>` | Get text response |
|
|
546
579
|
| `.json(example, desc?, opts?)` | `Promise<object\|array>` | Get structured JSON |
|
|
547
580
|
| `.raw()` | `Promise<{message, think, toolCalls, tokens, response}>` | Full response |
|
package/test/grok.test.js
CHANGED
|
@@ -2,6 +2,7 @@ const { expect } = require('chai');
|
|
|
2
2
|
const nock = require('nock');
|
|
3
3
|
const { ModelMix, MixGrok } = require('../index.js');
|
|
4
4
|
const {
|
|
5
|
+
mapEffort,
|
|
5
6
|
resolveGrok420ModelKey,
|
|
6
7
|
GROK420_ALIAS,
|
|
7
8
|
GROK420_REASONING,
|
|
@@ -10,6 +11,7 @@ const {
|
|
|
10
11
|
|
|
11
12
|
describe('Grok Model Registration Tests', () => {
|
|
12
13
|
const grokModels = [
|
|
14
|
+
{ method: 'grok46', key: 'grok-4.6' },
|
|
13
15
|
{ method: 'grok45', key: 'grok-4.5' },
|
|
14
16
|
{ method: 'grok43', key: 'grok-4.3' },
|
|
15
17
|
{ method: 'grok420multiAgent', key: 'grok-4.20-multi-agent-0309' },
|
|
@@ -25,6 +27,78 @@ describe('Grok Model Registration Tests', () => {
|
|
|
25
27
|
expect(model.models[0].key).to.equal(grokModel.key);
|
|
26
28
|
});
|
|
27
29
|
}
|
|
30
|
+
|
|
31
|
+
it('forwards options and config through grok46()', () => {
|
|
32
|
+
const options = { reasoning_effort: 'xhigh' };
|
|
33
|
+
const config = { max_history: 3 };
|
|
34
|
+
const model = ModelMix.new().grok46({ options, config });
|
|
35
|
+
|
|
36
|
+
expect(model.models[0].provider).to.be.instanceOf(MixGrok);
|
|
37
|
+
expect(model.models[0].provider.options).to.deep.equal(options);
|
|
38
|
+
expect(model.models[0].provider.config).to.include(config);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('maps unified effort to Grok 4.6 supported levels', () => {
|
|
42
|
+
expect(mapEffort('openai', 0, 'grok-4.6')).to.deep.equal({ reasoning_effort: 'low' });
|
|
43
|
+
expect(mapEffort('openai', 39, 'grok-4.6')).to.deep.equal({ reasoning_effort: 'low' });
|
|
44
|
+
expect(mapEffort('openai', 40, 'grok-4.6')).to.deep.equal({ reasoning_effort: 'medium' });
|
|
45
|
+
expect(mapEffort('openai', 60, 'grok-4.6')).to.deep.equal({ reasoning_effort: 'high' });
|
|
46
|
+
expect(mapEffort('openai', 100, 'grok-4.6')).to.deep.equal({ reasoning_effort: 'xhigh' });
|
|
47
|
+
expect(mapEffort('openai', -1, 'grok-4.6')).to.equal(null);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('sends a supported Grok 4.6 reasoning effort', async () => {
|
|
51
|
+
const originalApiKey = process.env.XAI_API_KEY;
|
|
52
|
+
process.env.XAI_API_KEY = 'test-key';
|
|
53
|
+
const api = nock('https://api.x.ai')
|
|
54
|
+
.post('/v1/chat/completions', body => {
|
|
55
|
+
expect(body.model).to.equal('grok-4.6');
|
|
56
|
+
expect(body.reasoning_effort).to.equal('low');
|
|
57
|
+
return true;
|
|
58
|
+
})
|
|
59
|
+
.reply(200, {
|
|
60
|
+
choices: [{ message: { content: 'ok' } }],
|
|
61
|
+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const response = await ModelMix.new()
|
|
66
|
+
.effort(0)
|
|
67
|
+
.grok46({ config: { apiKey: 'test-key' } })
|
|
68
|
+
.addText('Hi')
|
|
69
|
+
.message();
|
|
70
|
+
|
|
71
|
+
expect(response).to.equal('ok');
|
|
72
|
+
api.done();
|
|
73
|
+
} finally {
|
|
74
|
+
if (originalApiKey === undefined) delete process.env.XAI_API_KEY;
|
|
75
|
+
else process.env.XAI_API_KEY = originalApiKey;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('calculates Grok 4.6 cache and long-context costs', () => {
|
|
80
|
+
expect(ModelMix.calculateCostBreakdown('grok-4.6', {
|
|
81
|
+
input: 1_000_000,
|
|
82
|
+
output: 1_000_000,
|
|
83
|
+
cached: 1_000_000
|
|
84
|
+
})).to.deep.equal({
|
|
85
|
+
uncachedInput: 0,
|
|
86
|
+
cachedInput: 1,
|
|
87
|
+
cacheWrite: 0,
|
|
88
|
+
cacheWrite5m: 0,
|
|
89
|
+
cacheWrite1h: 0,
|
|
90
|
+
output: 12,
|
|
91
|
+
total: 13
|
|
92
|
+
});
|
|
93
|
+
expect(ModelMix.calculateCost('grok-4.6', {
|
|
94
|
+
input: 199_999,
|
|
95
|
+
output: 1_000_000
|
|
96
|
+
})).to.equal(6.399998);
|
|
97
|
+
expect(ModelMix.calculateCost('grok-4.6', {
|
|
98
|
+
input: 200_000,
|
|
99
|
+
output: 1_000_000
|
|
100
|
+
})).to.equal(12.8);
|
|
101
|
+
});
|
|
28
102
|
});
|
|
29
103
|
|
|
30
104
|
describe('Grok 4.20 effort → model resolution', () => {
|
package/test/live.mcp.js
CHANGED
|
@@ -233,8 +233,8 @@ describe('Live MCP Integration Tests', function () {
|
|
|
233
233
|
|
|
234
234
|
describe('Advanced MCP Tool Integration', function () {
|
|
235
235
|
|
|
236
|
-
it('should use multiple MCP tools with Grok 4.
|
|
237
|
-
const model = ModelMix.new(setup).
|
|
236
|
+
it('should use multiple MCP tools with Grok 4.6', async function () {
|
|
237
|
+
const model = ModelMix.new(setup).grok46();
|
|
238
238
|
|
|
239
239
|
// Add multiple tools
|
|
240
240
|
model.addTools([
|
package/test/live.test.js
CHANGED
|
@@ -193,7 +193,7 @@ describe('Live Integration Tests', function () {
|
|
|
193
193
|
});
|
|
194
194
|
|
|
195
195
|
const grokSeriesTests = [
|
|
196
|
-
{ name: 'Grok 4.
|
|
196
|
+
{ name: 'Grok 4.6', factory: (m) => m.grok46(), token: 'grok46' },
|
|
197
197
|
{ name: 'Grok 4.20 reasoning', factory: (m) => m.effort(50).grok420(), token: 'grok420' },
|
|
198
198
|
{ name: 'Grok 4.20 non-reasoning', factory: (m) => m.grok420(), token: 'grok420nr' }
|
|
199
199
|
];
|
|
@@ -216,8 +216,8 @@ describe('Live Integration Tests', function () {
|
|
|
216
216
|
|
|
217
217
|
describe('Image Processing with JSON Output', function () {
|
|
218
218
|
|
|
219
|
-
it('should process images and return JSON with Grok 4.
|
|
220
|
-
const model = ModelMix.new(setup).
|
|
219
|
+
it('should process images and return JSON with Grok 4.6', async function () {
|
|
220
|
+
const model = ModelMix.new(setup).grok46();
|
|
221
221
|
|
|
222
222
|
model.addImageFromUrl(blueSquareBase64)
|
|
223
223
|
.addText('Analyze this image and provide details in JSON format.');
|
|
@@ -228,7 +228,7 @@ describe('Live Integration Tests', function () {
|
|
|
228
228
|
description: "string"
|
|
229
229
|
});
|
|
230
230
|
|
|
231
|
-
console.log(`Grok 4.
|
|
231
|
+
console.log(`Grok 4.6 image JSON result:`, result);
|
|
232
232
|
|
|
233
233
|
expect(result).to.be.an('object');
|
|
234
234
|
expect(result).to.have.property('color');
|
|
@@ -263,8 +263,8 @@ describe('Live Integration Tests', function () {
|
|
|
263
263
|
expect(result.features).to.be.an('array');
|
|
264
264
|
});
|
|
265
265
|
|
|
266
|
-
it('should return structured JSON with Grok 4.
|
|
267
|
-
const model = ModelMix.new(setup).
|
|
266
|
+
it('should return structured JSON with Grok 4.6', async function () {
|
|
267
|
+
const model = ModelMix.new(setup).grok46();
|
|
268
268
|
|
|
269
269
|
model.addText('Generate information about a fictional technology.');
|
|
270
270
|
|
|
@@ -275,7 +275,7 @@ describe('Live Integration Tests', function () {
|
|
|
275
275
|
power: "1000 qubits"
|
|
276
276
|
});
|
|
277
277
|
|
|
278
|
-
console.log(`Grok 4.
|
|
278
|
+
console.log(`Grok 4.6 JSON result:`, result);
|
|
279
279
|
|
|
280
280
|
expect(result).to.be.an('object');
|
|
281
281
|
expect(result).to.have.property('name');
|
package/test/templates.test.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
const { expect } = require('chai');
|
|
2
2
|
const sinon = require('sinon');
|
|
3
3
|
const nock = require('nock');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
4
6
|
const path = require('path');
|
|
5
7
|
const { ModelMix } = require('../index.js');
|
|
6
8
|
|
|
@@ -37,7 +39,7 @@ describe('EJS Template and File Operations Tests', () => {
|
|
|
37
39
|
it('renders inline variables with plain data keys', async () => {
|
|
38
40
|
const model = ModelMix.new()
|
|
39
41
|
.gpt51()
|
|
40
|
-
.
|
|
42
|
+
.assign({ name: 'Alice', age: 30, city: 'New York' })
|
|
41
43
|
.addText('Hello <%- name %>, you are <%- age %> years old and live in <%- city %>.');
|
|
42
44
|
|
|
43
45
|
mockOpenAI(body => {
|
|
@@ -49,10 +51,23 @@ describe('EJS Template and File Operations Tests', () => {
|
|
|
49
51
|
await model.message();
|
|
50
52
|
});
|
|
51
53
|
|
|
54
|
+
it('assigns one template data key', async () => {
|
|
55
|
+
const model = ModelMix.new()
|
|
56
|
+
.gpt51()
|
|
57
|
+
.assignKey('name', 'Martin')
|
|
58
|
+
.addText('Hello <%- name %>.');
|
|
59
|
+
|
|
60
|
+
mockOpenAI(body => {
|
|
61
|
+
expect(userTexts(body)).to.deep.equal(['Hello Martin.']);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await model.message();
|
|
65
|
+
});
|
|
66
|
+
|
|
52
67
|
it('supports nested data, conditionals, and loops', async () => {
|
|
53
68
|
const model = ModelMix.new()
|
|
54
69
|
.gpt51()
|
|
55
|
-
.
|
|
70
|
+
.assign({
|
|
56
71
|
user: {
|
|
57
72
|
name: 'Charlie',
|
|
58
73
|
active: true,
|
|
@@ -72,7 +87,7 @@ describe('EJS Template and File Operations Tests', () => {
|
|
|
72
87
|
const value = 'Hello & "World" <test>';
|
|
73
88
|
const model = ModelMix.new()
|
|
74
89
|
.gpt51()
|
|
75
|
-
.
|
|
90
|
+
.assign({ value })
|
|
76
91
|
.addText('Escaped: <%= value %>\nRaw: <%- value %>');
|
|
77
92
|
|
|
78
93
|
mockOpenAI(body => {
|
|
@@ -87,7 +102,7 @@ describe('EJS Template and File Operations Tests', () => {
|
|
|
87
102
|
it('does not execute EJS received through template data', async () => {
|
|
88
103
|
const model = ModelMix.new()
|
|
89
104
|
.gpt51()
|
|
90
|
-
.
|
|
105
|
+
.assign({ payload: '<%- secret %>', secret: 'must-not-render' })
|
|
91
106
|
.addText('Payload: <%- payload %>');
|
|
92
107
|
|
|
93
108
|
mockOpenAI(body => {
|
|
@@ -120,7 +135,7 @@ Do not use emojis.
|
|
|
120
135
|
it('selects weighted options using relative weights', async () => {
|
|
121
136
|
const model = ModelMix.new()
|
|
122
137
|
.gpt51()
|
|
123
|
-
.
|
|
138
|
+
.assign({ language: 'Spanish' })
|
|
124
139
|
.addText(`<% choice %>
|
|
125
140
|
<% option 20 %>
|
|
126
141
|
Use emojis in <%- language %>.
|
|
@@ -194,7 +209,7 @@ second
|
|
|
194
209
|
it('fails before the request when a variable is missing', async () => {
|
|
195
210
|
const model = ModelMix.new()
|
|
196
211
|
.gpt51()
|
|
197
|
-
.
|
|
212
|
+
.assign({ name: 'David' })
|
|
198
213
|
.addText('Hello <%- name %>, status: <%- status %>');
|
|
199
214
|
|
|
200
215
|
let error;
|
|
@@ -212,14 +227,22 @@ second
|
|
|
212
227
|
it('rejects invalid template data immediately', () => {
|
|
213
228
|
const model = ModelMix.new().gpt51();
|
|
214
229
|
|
|
215
|
-
expect(() => model.
|
|
216
|
-
expect(() => model.
|
|
217
|
-
expect(() => model.
|
|
218
|
-
expect(() => ModelMix.new({ config: {
|
|
230
|
+
expect(() => model.assign(null)).to.throw(TypeError, 'Template data must be a plain non-null object.');
|
|
231
|
+
expect(() => model.assign(undefined)).to.throw(TypeError, 'Template data must be a plain non-null object.');
|
|
232
|
+
expect(() => model.assign([])).to.throw(TypeError, 'Template data must be a plain non-null object.');
|
|
233
|
+
expect(() => ModelMix.new({ config: { templateData: null } })).to.throw(
|
|
219
234
|
TypeError,
|
|
220
235
|
'Template data must be a plain non-null object.'
|
|
221
236
|
);
|
|
222
|
-
expect(() => model.
|
|
237
|
+
expect(() => model.assign({ $mix: 'reserved' })).to.throw(
|
|
238
|
+
TypeError,
|
|
239
|
+
'Template data key "$mix" is reserved.'
|
|
240
|
+
);
|
|
241
|
+
expect(() => model.assignKey('', 'value')).to.throw(
|
|
242
|
+
TypeError,
|
|
243
|
+
'Template data key must be a non-empty string.'
|
|
244
|
+
);
|
|
245
|
+
expect(() => model.assignKey('$mix', 'value')).to.throw(
|
|
223
246
|
TypeError,
|
|
224
247
|
'Template data key "$mix" is reserved.'
|
|
225
248
|
);
|
|
@@ -291,7 +314,7 @@ B
|
|
|
291
314
|
expect(error).to.be.instanceOf(Error);
|
|
292
315
|
expect(model.messages[0].content[0].text).to.include('<% choice %>');
|
|
293
316
|
|
|
294
|
-
model.
|
|
317
|
+
model.assign({ missing: 'ready' });
|
|
295
318
|
mockOpenAI(body => {
|
|
296
319
|
const text = userTexts(body).join('\n').trim();
|
|
297
320
|
expect(text).to.include('B');
|
|
@@ -374,7 +397,7 @@ B
|
|
|
374
397
|
it('renders a file template with a relative include', async () => {
|
|
375
398
|
const model = ModelMix.new()
|
|
376
399
|
.gpt51()
|
|
377
|
-
.
|
|
400
|
+
.assign({
|
|
378
401
|
name: 'Eve',
|
|
379
402
|
platform: 'ModelMix',
|
|
380
403
|
username: 'eve_user',
|
|
@@ -398,6 +421,19 @@ B
|
|
|
398
421
|
await model.message();
|
|
399
422
|
});
|
|
400
423
|
|
|
424
|
+
it('resolves a dynamic include path relative to its template', async () => {
|
|
425
|
+
const model = ModelMix.new()
|
|
426
|
+
.gpt51()
|
|
427
|
+
.assign({ rulesFile: 'system-rules.txt', language: 'Spanish' })
|
|
428
|
+
.addTextFromFile(path.join(fixturesPath, 'dynamic-include.txt'));
|
|
429
|
+
|
|
430
|
+
mockOpenAI(body => {
|
|
431
|
+
expect(userTexts(body)[0].trim()).to.equal('Rules:\nAlways respond in Spanish.');
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
await model.message();
|
|
435
|
+
});
|
|
436
|
+
|
|
401
437
|
it('processes choice directives inside relative includes', async () => {
|
|
402
438
|
const model = ModelMix.new()
|
|
403
439
|
.gpt51()
|
|
@@ -411,10 +447,40 @@ B
|
|
|
411
447
|
await model.message();
|
|
412
448
|
});
|
|
413
449
|
|
|
450
|
+
it('supports recursive includes with an explicit depth limit', async () => {
|
|
451
|
+
const model = ModelMix.new()
|
|
452
|
+
.gpt51()
|
|
453
|
+
.assign({
|
|
454
|
+
node: {
|
|
455
|
+
text: 'Root',
|
|
456
|
+
children: [{
|
|
457
|
+
text: 'Child',
|
|
458
|
+
children: [{
|
|
459
|
+
text: 'Grandchild',
|
|
460
|
+
children: [{ text: 'Too deep', children: [] }]
|
|
461
|
+
}]
|
|
462
|
+
}]
|
|
463
|
+
},
|
|
464
|
+
depth: 0,
|
|
465
|
+
maxDepth: 2
|
|
466
|
+
})
|
|
467
|
+
.addTextFromFile(path.join(fixturesPath, 'tree.ejs'));
|
|
468
|
+
|
|
469
|
+
mockOpenAI(body => {
|
|
470
|
+
const content = userTexts(body)[0];
|
|
471
|
+
expect(content).to.include('Root');
|
|
472
|
+
expect(content).to.include('Child');
|
|
473
|
+
expect(content).to.include('Grandchild');
|
|
474
|
+
expect(content).to.not.include('Too deep');
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
await model.message();
|
|
478
|
+
});
|
|
479
|
+
|
|
414
480
|
it('preserves a system template filename through new instances', async () => {
|
|
415
481
|
const base = ModelMix.new()
|
|
416
482
|
.setSystemFromFile(path.join(fixturesPath, 'system-template.txt'))
|
|
417
|
-
.
|
|
483
|
+
.assign({ role: 'data analyst', language: 'Spanish' });
|
|
418
484
|
const model = base.new().gpt51().addText('Analyze this.');
|
|
419
485
|
|
|
420
486
|
mockOpenAI(body => {
|
|
@@ -426,26 +492,75 @@ B
|
|
|
426
492
|
await model.message();
|
|
427
493
|
});
|
|
428
494
|
|
|
429
|
-
it('
|
|
495
|
+
it('renders assigned files through EJS includes, including their relative includes', async () => {
|
|
430
496
|
const model = ModelMix.new()
|
|
431
497
|
.gpt51()
|
|
432
|
-
.
|
|
433
|
-
|
|
498
|
+
.assign({
|
|
499
|
+
name: 'Eve',
|
|
500
|
+
platform: 'ModelMix',
|
|
501
|
+
username: 'eve_user',
|
|
502
|
+
role: 'developer',
|
|
503
|
+
createdDate: '2026-08-07',
|
|
504
|
+
website: 'https://modelmix.dev',
|
|
505
|
+
company: 'AI Solutions',
|
|
506
|
+
showAccount: true
|
|
507
|
+
})
|
|
508
|
+
.assignKeyFromFile('templateSource', path.join(fixturesPath, 'template.txt'))
|
|
434
509
|
.addText('Source:\n<%- templateSource %>');
|
|
435
510
|
|
|
436
511
|
mockOpenAI(body => {
|
|
437
512
|
const content = userTexts(body)[0];
|
|
438
|
-
expect(content).to.include('Hello
|
|
439
|
-
expect(content).to.
|
|
513
|
+
expect(content).to.include('Hello Eve, welcome to ModelMix!');
|
|
514
|
+
expect(content).to.include('Username: eve_user');
|
|
515
|
+
expect(content).to.include('The AI Solutions Team');
|
|
516
|
+
expect(content).to.not.include('<%-');
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
await model.message();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('inherits assigned files through new instances', async () => {
|
|
523
|
+
const base = ModelMix.new()
|
|
524
|
+
.assign({ language: 'Spanish' })
|
|
525
|
+
.assignKeyFromFile('rules', path.join(fixturesPath, 'system-rules.txt'));
|
|
526
|
+
const model = base.new().gpt51().addText('Rules:\n<%- rules %>');
|
|
527
|
+
|
|
528
|
+
mockOpenAI(body => {
|
|
529
|
+
expect(userTexts(body)[0].trim()).to.equal('Rules:\nAlways respond in Spanish.');
|
|
440
530
|
});
|
|
441
531
|
|
|
442
532
|
await model.message();
|
|
443
533
|
});
|
|
444
534
|
|
|
535
|
+
it('uses the latest assignment when a plain value and a file share a key', async () => {
|
|
536
|
+
const plainValue = ModelMix.new()
|
|
537
|
+
.gpt51()
|
|
538
|
+
.assign({ language: 'Spanish' })
|
|
539
|
+
.assignKeyFromFile('rules', path.join(fixturesPath, 'system-rules.txt'))
|
|
540
|
+
.assignKey('rules', 'Use the plain value.')
|
|
541
|
+
.addText('<%- rules %>');
|
|
542
|
+
|
|
543
|
+
mockOpenAI(body => {
|
|
544
|
+
expect(userTexts(body)).to.deep.equal(['Use the plain value.']);
|
|
545
|
+
}, 'Plain response');
|
|
546
|
+
await plainValue.message();
|
|
547
|
+
|
|
548
|
+
const fileValue = ModelMix.new()
|
|
549
|
+
.gpt51()
|
|
550
|
+
.assign({ language: 'Spanish', rules: 'Ignore this value.' })
|
|
551
|
+
.assignKeyFromFile('rules', path.join(fixturesPath, 'system-rules.txt'))
|
|
552
|
+
.addText('<%- rules %>');
|
|
553
|
+
|
|
554
|
+
mockOpenAI(body => {
|
|
555
|
+
expect(userTexts(body)[0].trim()).to.equal('Always respond in Spanish.');
|
|
556
|
+
}, 'File response');
|
|
557
|
+
await fileValue.message();
|
|
558
|
+
});
|
|
559
|
+
|
|
445
560
|
it('injects JSON file contents without XML escaping', async () => {
|
|
446
561
|
const model = ModelMix.new()
|
|
447
562
|
.gpt51()
|
|
448
|
-
.
|
|
563
|
+
.assignKeyFromFile('data', path.join(fixturesPath, 'data.json'))
|
|
449
564
|
.addText('Process this data:\n<%- data %>');
|
|
450
565
|
|
|
451
566
|
mockOpenAI(body => {
|
|
@@ -458,12 +573,48 @@ B
|
|
|
458
573
|
await model.message();
|
|
459
574
|
});
|
|
460
575
|
|
|
576
|
+
it('reloads an assigned file for each request', async () => {
|
|
577
|
+
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'modelmix-template-'));
|
|
578
|
+
const assignedFile = path.join(temporaryDirectory, 'assigned.ejs');
|
|
579
|
+
|
|
580
|
+
try {
|
|
581
|
+
fs.writeFileSync(assignedFile, 'Version one for <%- name %>.');
|
|
582
|
+
const model = ModelMix.new()
|
|
583
|
+
.gpt51()
|
|
584
|
+
.assign({ name: 'Eve' })
|
|
585
|
+
.assignKeyFromFile('content', assignedFile)
|
|
586
|
+
.addText('<%- content %>');
|
|
587
|
+
|
|
588
|
+
mockOpenAI(body => {
|
|
589
|
+
expect(userTexts(body)).to.deep.equal(['Version one for Eve.']);
|
|
590
|
+
}, 'First response');
|
|
591
|
+
await model.message();
|
|
592
|
+
|
|
593
|
+
fs.writeFileSync(assignedFile, 'Version two for <%- name %>.');
|
|
594
|
+
model.assign({ name: 'Ada' }).addText('<%- content %>');
|
|
595
|
+
mockOpenAI(body => {
|
|
596
|
+
expect(userTexts(body)).to.deep.equal(['Version two for Ada.']);
|
|
597
|
+
}, 'Second response');
|
|
598
|
+
await model.message();
|
|
599
|
+
} finally {
|
|
600
|
+
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
|
|
461
604
|
it('throws immediately when a template or data file is missing', () => {
|
|
462
605
|
const model = ModelMix.new().gpt51();
|
|
463
606
|
const missingPath = path.join(fixturesPath, 'nonexistent.txt');
|
|
464
607
|
|
|
465
608
|
expect(() => model.addTextFromFile(missingPath)).to.throw(`File not found: ${missingPath}`);
|
|
466
|
-
expect(() => model.
|
|
609
|
+
expect(() => model.assignKeyFromFile('missing', missingPath)).to.throw(`File not found: ${missingPath}`);
|
|
610
|
+
expect(() => model.assignKeyFromFile('', missingPath)).to.throw(
|
|
611
|
+
TypeError,
|
|
612
|
+
'Template data key must be a non-empty string.'
|
|
613
|
+
);
|
|
614
|
+
expect(() => model.assignKeyFromFile('$mix', missingPath)).to.throw(
|
|
615
|
+
TypeError,
|
|
616
|
+
'Template data key "$mix" is reserved.'
|
|
617
|
+
);
|
|
467
618
|
});
|
|
468
619
|
});
|
|
469
620
|
|
|
@@ -473,8 +624,8 @@ B
|
|
|
473
624
|
const model = ModelMix.new()
|
|
474
625
|
.gpt51()
|
|
475
626
|
.setSystem('You are a <%- role %>.')
|
|
476
|
-
.
|
|
477
|
-
.
|
|
627
|
+
.assign({ role: 'data analyst', instruction: 'Count active users' })
|
|
628
|
+
.assignKeyFromFile('data', path.join(fixturesPath, 'data.json'))
|
|
478
629
|
.addText('<%- instruction %> from this data: <%- data %>');
|
|
479
630
|
|
|
480
631
|
nock('https://api.openai.com')
|
|
@@ -504,7 +655,7 @@ B
|
|
|
504
655
|
const model = ModelMix.new()
|
|
505
656
|
.gpt51()
|
|
506
657
|
.setSystem('Act as <%- role %>.')
|
|
507
|
-
.
|
|
658
|
+
.assign({ role: 'reviewer' })
|
|
508
659
|
.addText('Review this.');
|
|
509
660
|
|
|
510
661
|
mockOpenAI(body => {
|
|
@@ -520,7 +671,7 @@ B
|
|
|
520
671
|
it('keeps rendered history snapshots when template data changes', async () => {
|
|
521
672
|
const model = ModelMix.new({ config: { max_history: 10 } })
|
|
522
673
|
.gpt51()
|
|
523
|
-
.
|
|
674
|
+
.assign({ name: 'Alice' })
|
|
524
675
|
.addText('Hello <%- name %>.');
|
|
525
676
|
|
|
526
677
|
mockOpenAI(body => {
|
|
@@ -528,7 +679,7 @@ B
|
|
|
528
679
|
}, 'First response');
|
|
529
680
|
await model.message();
|
|
530
681
|
|
|
531
|
-
model.
|
|
682
|
+
model.assign({ name: 'Bob' }).addText('Hello <%- name %>.');
|
|
532
683
|
mockOpenAI(body => {
|
|
533
684
|
expect(userTexts(body)).to.deep.equal(['Hello Alice.', 'Hello Bob.']);
|
|
534
685
|
}, 'Second response');
|