modelmix 5.0.0 → 5.0.1

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
@@ -249,20 +249,27 @@ Templates are executable JavaScript and must be controlled by the developer. Pas
249
249
  | --- | --- |
250
250
  | `setSystemFromFile(path)` | Load the system prompt from a file |
251
251
  | `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 |
252
+ | `assign({ key: value })` | Assign EJS template data |
253
+ | `assignKey(key, value)` | Assign one EJS template-data value |
254
+ | `assignKeyFromFile(key, path)` | Assign an EJS-rendered file to one template-data key |
254
255
 
255
- ### Basic example with `replace`
256
+ ### Basic example with `assign`
256
257
 
257
258
  ```javascript
258
259
  const gpt = ModelMix.new().gpt52();
259
260
 
260
261
  gpt.addText('Write a short story about a <%- animal %> that lives in <%- place %>.');
261
- gpt.replace({ animal: 'cat', place: 'a haunted castle' });
262
+ gpt.assign({ animal: 'cat', place: 'a haunted castle' });
262
263
 
263
264
  console.log(await gpt.message());
264
265
  ```
265
266
 
267
+ Use `assignKey()` when assigning a single value:
268
+
269
+ ```javascript
270
+ gpt.assignKey('animal', 'cat');
271
+ ```
272
+
266
273
  ### Loading prompts from `.md` files
267
274
 
268
275
  Instead of writing long prompts inline, keep them in separate Markdown files. This makes them easier to read, edit, and version control.
@@ -287,7 +294,7 @@ const gpt = ModelMix.new().gpt56luna();
287
294
  gpt.setSystemFromFile('./prompts/system.md');
288
295
  gpt.addTextFromFile('./prompts/task.md');
289
296
 
290
- gpt.replace({
297
+ gpt.assign({
291
298
  role: 'a senior analyst',
292
299
  topic: 'market trends',
293
300
  language: 'Spanish',
@@ -297,15 +304,38 @@ gpt.replace({
297
304
  console.log(await gpt.message());
298
305
  ```
299
306
 
300
- ### Injecting file contents as template data
307
+ ### Simple includes
301
308
 
302
- Use `replaceKeyFromFile` when the replacement value itself is a large text stored in a file.
309
+ Use EJS `include` to compose a prompt from other files. Include paths are resolved relative to the template containing them.
310
+
311
+ ```ejs
312
+ <%- include('shared/rules.md') %>
313
+ ```
314
+
315
+ For example:
316
+
317
+ **`prompts/task.md`**
318
+ ```markdown
319
+ Analyze the request following these rules:
320
+
321
+ <%- include('shared/rules.md') %>
322
+ ```
323
+
324
+ **`prompts/shared/rules.md`**
325
+ ```markdown
326
+ - Be concise
327
+ - Explain assumptions
328
+ ```
329
+
330
+ ### Dynamic includes
331
+
332
+ 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
333
 
304
334
  **`prompts/summarize.md`**
305
335
  ```markdown
306
336
  Summarize the following article in 3 bullet points:
307
337
 
308
- <%- article %>
338
+ <%- include(articleFile) %>
309
339
  ```
310
340
 
311
341
  **`app.js`**
@@ -313,11 +343,29 @@ Summarize the following article in 3 bullet points:
313
343
  const gpt = ModelMix.new().gpt5mini();
314
344
 
315
345
  gpt.addTextFromFile('./prompts/summarize.md');
316
- gpt.replaceKeyFromFile('article', './data/article.md');
346
+ gpt.assign({ articleFile: '../data/article.md' });
317
347
 
318
348
  console.log(await gpt.message());
319
349
  ```
320
350
 
351
+ 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.
352
+
353
+ ### Assigning a rendered file to a key
354
+
355
+ Use `assignKeyFromFile()` when the outer template needs the rendered contents of a file as one data value:
356
+
357
+ ```javascript
358
+ const gpt = ModelMix.new().gpt5mini();
359
+
360
+ gpt.assign({ language: 'Spanish' });
361
+ gpt.assignKeyFromFile('rules', './prompts/rules.md');
362
+ gpt.addText('Follow these rules:\n<%- rules %>');
363
+
364
+ console.log(await gpt.message());
365
+ ```
366
+
367
+ `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.
368
+
321
369
  ### Full template workflow
322
370
 
323
371
  Combine all methods to build reusable, file-based prompt pipelines:
@@ -325,16 +373,21 @@ Combine all methods to build reusable, file-based prompt pipelines:
325
373
  **`prompts/system.md`**
326
374
  ```markdown
327
375
  You are <%- role %>. Follow these rules:
376
+ <%- include('partials/rules.md') %>
377
+ - Respond in <%- language %>
378
+ ```
379
+
380
+ **`prompts/partials/rules.md`**
381
+ ```markdown
328
382
  - Be concise
329
383
  - Use examples when possible
330
- - Respond in <%- language %>
331
384
  ```
332
385
 
333
386
  **`prompts/review.md`**
334
387
  ```markdown
335
388
  Review the following code and suggest improvements:
336
389
 
337
- <%- code %>
390
+ <%- include('../src/utils.js') %>
338
391
  ```
339
392
 
340
393
  **`app.js`**
@@ -344,8 +397,7 @@ const gpt = ModelMix.new().gpt5mini();
344
397
  gpt.setSystemFromFile('./prompts/system.md');
345
398
  gpt.addTextFromFile('./prompts/review.md');
346
399
 
347
- gpt.replace({ role: 'a senior code reviewer', language: 'English' });
348
- gpt.replaceKeyFromFile('code', './src/utils.js');
400
+ gpt.assign({ role: 'a senior code reviewer', language: 'English' });
349
401
 
350
402
  console.log(await gpt.message());
351
403
  ```
@@ -393,13 +445,30 @@ Do not use emojis.
393
445
 
394
446
  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
447
 
396
- File templates can include files relative to their own path:
448
+ ### Recursive includes
449
+
450
+ An included template can include itself to render recursive data. Always define a stopping condition:
397
451
 
398
452
  ```ejs
399
- <%- include('shared/rules.md') %>
453
+ <%- node.text %>
454
+
455
+ <% if (node.children?.length && depth < maxDepth) { %>
456
+ <% for (const child of node.children) { %>
457
+ <%- include('tree.ejs', { node: child, depth: depth + 1, maxDepth }) %>
458
+ <% } %>
459
+ <% } %>
460
+ ```
461
+
462
+ ```javascript
463
+ const gpt = ModelMix.new().gpt5mini();
464
+
465
+ gpt.addTextFromFile('./prompts/tree.ejs');
466
+ gpt.assign({ node: promptTree, depth: 0, maxDepth: 10 });
467
+
468
+ console.log(await gpt.message());
400
469
  ```
401
470
 
402
- Content supplied through `replace()` or `replaceKeyFromFile()` is rendered once as data. EJS tags inside that content are not executed recursively.
471
+ 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
472
 
404
473
  ## 🧩 JSON Structured Output
405
474
 
@@ -800,8 +869,9 @@ new ModelMix(args = { options: {}, config: {} })
800
869
  - `addTextFromFile(filePath, config = { role: "user", cache? })`: Adds a text message from a file.
801
870
  - `addImage(filePath, config = { role: "user", cache? })`: Adds an image message from a file path.
802
871
  - `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.
872
+ - `assign(keyValues)`: Assigns EJS data for messages and system prompts.
873
+ - `assignKey(key, value)`: Assigns one EJS data value.
874
+ - `assignKeyFromFile(key, filePath)`: Renders an EJS file through `include` and assigns its output to one key.
805
875
  - `message()`: Sends the message and returns the response.
806
876
  - `raw()`: Sends the message and returns the complete response data including:
807
877
  - `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/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
 
@@ -451,7 +452,7 @@ export declare class ModelMix {
451
452
  raw(): Promise<ModelMixResult>;
452
453
  stream(callback: StreamCallback): Promise<ModelMixResult>;
453
454
 
454
- replaceKeyFromFile(key: string, filePath: string): this;
455
+ assignKeyFromFile(key: string, filePath: string): this;
455
456
  groupByRoles(messages: ChatMessage[]): ChatMessage[];
456
457
  prepareMessages(): Promise<void>;
457
458
  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
  };
@@ -354,6 +364,7 @@ class ModelMix {
354
364
  this.toolClient = {};
355
365
  this.mcp = {};
356
366
  this.mcpToolsManager = new MCPToolsManager();
367
+ this.templateFileAssignments = new Map();
357
368
  this.messageTemplates = new WeakMap();
358
369
  this.lastRaw = null;
359
370
  this.options = {
@@ -387,8 +398,8 @@ class ModelMix {
387
398
  source: this.config.system,
388
399
  filename: null
389
400
  };
390
- if (this.config.replace !== undefined) {
391
- validateTemplateData(this.config.replace);
401
+ if (this.config.templateData !== undefined) {
402
+ validateTemplateData(this.config.templateData);
392
403
  }
393
404
  // Unified effort is ModelMix policy (config.effort / .effort()), not a native option.
394
405
  if (this.config.effort !== undefined && this.config.effort !== null) {
@@ -401,12 +412,20 @@ class ModelMix {
401
412
 
402
413
  }
403
414
 
404
- replace(keyValues) {
415
+ assign(keyValues) {
405
416
  validateTemplateData(keyValues);
406
- this.config.replace = { ...this.config.replace, ...keyValues };
417
+ for (const key of Object.keys(keyValues)) {
418
+ this.templateFileAssignments.delete(key);
419
+ }
420
+ this.config.templateData = { ...this.config.templateData, ...keyValues };
407
421
  return this;
408
422
  }
409
423
 
424
+ assignKey(key, value) {
425
+ validateTemplateDataKey(key);
426
+ return this.assign({ [key]: value });
427
+ }
428
+
410
429
  /**
411
430
  * Set unified reasoning effort: -1 (adaptive) or 0..100.
412
431
  * Stored in config.effort; mapped to provider-native fields at request time
@@ -431,6 +450,10 @@ class ModelMix {
431
450
  if (!hasSystemOverride) {
432
451
  instance.systemTemplate = { ...this.systemTemplate };
433
452
  }
453
+ instance.templateFileAssignments = new Map(this.templateFileAssignments);
454
+ for (const key of Object.keys(config.templateData || {})) {
455
+ instance.templateFileAssignments.delete(key);
456
+ }
434
457
  instance.models = this.models; // Share models array for round-robin rotation
435
458
  return instance;
436
459
  }
@@ -1217,27 +1240,71 @@ class ModelMix {
1217
1240
  return this.execute({ options: { stream: true } });
1218
1241
  }
1219
1242
 
1220
- replaceKeyFromFile(key, filePath) {
1221
- const content = this.readFile(filePath);
1222
- return this.replace({ [key]: content });
1243
+ assignKeyFromFile(key, filePath) {
1244
+ validateTemplateDataKey(key);
1245
+ this.readFile(filePath);
1246
+
1247
+ const templateData = { ...this.config.templateData };
1248
+ delete templateData[key];
1249
+ this.config.templateData = templateData;
1250
+ this.templateFileAssignments.set(key, Object.freeze({
1251
+ key,
1252
+ filename: path.resolve(filePath)
1253
+ }));
1254
+ return this;
1223
1255
  }
1224
1256
 
1225
1257
  _choiceRandom() {
1226
1258
  return Math.random();
1227
1259
  }
1228
1260
 
1261
+ _templateData(renderContext) {
1262
+ const assigned = { ...(this.config.templateData || {}), $mix: renderContext.helpers };
1263
+ const data = { ...assigned };
1264
+
1265
+ for (const [key, assignment] of this.templateFileAssignments) {
1266
+ data[key] = this._renderAssignedTemplate(assignment, assigned, renderContext);
1267
+ }
1268
+
1269
+ return data;
1270
+ }
1271
+
1272
+ _renderAssignedTemplate(assignment, data, renderContext) {
1273
+ if (renderContext.renderedTemplateData.has(assignment)) {
1274
+ return renderContext.renderedTemplateData.get(assignment);
1275
+ }
1276
+
1277
+ const rendered = this._renderTemplateWithData(
1278
+ `<%- include(${JSON.stringify(assignment.filename)}) %>`,
1279
+ {
1280
+ filename: assignment.filename,
1281
+ label: `template data "${assignment.key}"`
1282
+ },
1283
+ data
1284
+ );
1285
+ renderContext.renderedTemplateData.set(assignment, rendered);
1286
+ return rendered;
1287
+ }
1288
+
1229
1289
  _renderTemplate(
1230
1290
  source,
1231
1291
  { filename = null, label = 'template' } = {},
1232
1292
  renderContext = createTemplateRenderContext(() => this._choiceRandom())
1233
1293
  ) {
1294
+ return this._renderTemplateWithData(
1295
+ source,
1296
+ { filename, label },
1297
+ this._templateData(renderContext)
1298
+ );
1299
+ }
1300
+
1301
+ _renderTemplateWithData(source, { filename = null, label = 'template' }, data) {
1234
1302
  if (typeof source !== 'string') {
1235
1303
  throw new TypeError(`${label} source must be a string.`);
1236
1304
  }
1237
1305
 
1238
1306
  try {
1239
1307
  const template = preprocessChoiceDirectives(source, { filename, label });
1240
- const data = { ...(this.config.replace || {}), $mix: renderContext.helpers };
1241
1308
  return ejs.render(template, data, {
1242
1309
  ...(filename && { filename }),
1243
1310
  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.1",
4
4
  "description": "🧬 Reliable interface with automatic fallback for AI LLMs.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -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
+ <% } %>
@@ -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');