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 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
- .grok43() // (fallback 4) Grok grok-4.3
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
- | `replace({ key: value })` | Add EJS template data |
253
- | `replaceKeyFromFile(key, path)` | Add a file's raw contents as template data |
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 `replace`
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.replace({ animal: 'cat', place: 'a haunted castle' });
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.replace({
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
- ### Injecting file contents as template data
308
+ ### Simple includes
301
309
 
302
- Use `replaceKeyFromFile` when the replacement value itself is a large text stored in a file.
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
- <%- article %>
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.replaceKeyFromFile('article', './data/article.md');
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
- <%- code %>
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.replace({ role: 'a senior code reviewer', language: 'English' });
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
- File templates can include files relative to their own path:
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
- <%- include('shared/rules.md') %>
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 `replace()` or `replaceKeyFromFile()` is rendered once as data. EJS tags inside that content are not executed recursively.
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
- - `replace(keyValues)`: Adds EJS data for messages and system prompts.
804
- - `replaceKeyFromFile(key, filePath)`: Adds raw file contents as an EJS data value.
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.replace({ name: 'ALF' });
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.replace({ animal: 'cat' });
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
@@ -13,9 +13,9 @@ const mmix = new ModelMix({
13
13
  });
14
14
 
15
15
 
16
- const r = await mmix.grok43()
16
+ const r = await mmix.grok46()
17
17
  .addText('hi there!')
18
18
  .addText('do you like cats?')
19
19
  .raw();
20
20
 
21
- console.log(r);
21
+ console.log(r);
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 } }).grok43()
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
- .grok43() // (fallback 4) Grok grok-4.3
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
- replace?: Record<string, unknown>;
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
- replace(keyValues: Record<string, unknown>): this;
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
- replaceKeyFromFile(key: string, filePath: string): this;
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.replace !== undefined) {
391
- validateTemplateData(this.config.replace);
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
- replace(keyValues) {
431
+ assign(keyValues) {
405
432
  validateTemplateData(keyValues);
406
- this.config.replace = { ...this.config.replace, ...keyValues };
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 = longContext && normalized.input > longContext.inputThreshold;
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 inputMultiplier = longContext && normalized.input > longContext.inputThreshold
568
- ? longContext.inputMultiplier
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
- replaceKeyFromFile(key, filePath) {
1221
- const content = this.readFile(filePath);
1222
- return this.replace({ [key]: content });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelmix",
3
- "version": "5.0.0",
3
+ "version": "5.0.2",
4
4
  "description": "🧬 Reliable interface with automatic fallback for AI LLMs.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -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.replace({
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 `replace()` data. Relative includes work in templates loaded from files.
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
- | `.replace({})` | `this` | Add EJS template data |
544
- | `.replaceKeyFromFile(key, path)` | `this` | Add raw file content as EJS template data |
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 |
@@ -0,0 +1,2 @@
1
+ Rules:
2
+ <%- include(rulesFile) %>
@@ -0,0 +1,6 @@
1
+ <%- node.text %>
2
+ <% if (node.children?.length && depth < maxDepth) { %>
3
+ <% for (const child of node.children) { %>
4
+ <%- include('tree.ejs', { node: child, depth: depth + 1, maxDepth }) %>
5
+ <% } %>
6
+ <% } %>
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.3', async function () {
237
- const model = ModelMix.new(setup).grok43();
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.3', factory: (m) => m.grok43(), token: 'grok43' },
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.3', async function () {
220
- const model = ModelMix.new(setup).grok43();
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.3 image JSON result:`, result);
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.3', async function () {
267
- const model = ModelMix.new(setup).grok43();
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.3 JSON result:`, result);
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');
@@ -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
- .replace({ name: 'Alice', age: 30, city: 'New York' })
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
- .replace({
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
- .replace({ value })
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
- .replace({ payload: '<%- secret %>', secret: 'must-not-render' })
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
- .replace({ language: 'Spanish' })
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
- .replace({ name: 'David' })
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.replace(null)).to.throw(TypeError, 'Template data must be a plain non-null object.');
216
- expect(() => model.replace(undefined)).to.throw(TypeError, 'Template data must be a plain non-null object.');
217
- expect(() => model.replace([])).to.throw(TypeError, 'Template data must be a plain non-null object.');
218
- expect(() => ModelMix.new({ config: { replace: null } })).to.throw(
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.replace({ $mix: 'reserved' })).to.throw(
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.replace({ missing: 'ready' });
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
- .replace({
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
- .replace({ role: 'data analyst', language: 'Spanish' });
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('injects file contents as raw data without recursively rendering them', async () => {
495
+ it('renders assigned files through EJS includes, including their relative includes', async () => {
430
496
  const model = ModelMix.new()
431
497
  .gpt51()
432
- .replaceKeyFromFile('templateSource', path.join(fixturesPath, 'template.txt'))
433
- .replace({ name: 'must-not-render' })
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 <%- name %>, welcome to <%- platform %>!');
439
- expect(content).to.not.include('Hello must-not-render');
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
- .replaceKeyFromFile('data', path.join(fixturesPath, 'data.json'))
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.replaceKeyFromFile('missing', missingPath)).to.throw(`File not found: ${missingPath}`);
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
- .replace({ role: 'data analyst', instruction: 'Count active users' })
477
- .replaceKeyFromFile('data', path.join(fixturesPath, 'data.json'))
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
- .replace({ role: 'reviewer' })
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
- .replace({ name: 'Alice' })
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.replace({ name: 'Bob' }).addText('Hello <%- name %>.');
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');