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 +43 -0
- package/README.md +88 -18
- package/demo/demo.js +2 -2
- package/index.d.ts +4 -3
- package/index.js +75 -8
- package/package.json +1 -1
- package/skills/modelmix/SKILL.md +38 -5
- package/test/fixtures/dynamic-include.txt +2 -0
- package/test/fixtures/tree.ejs +6 -0
- 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
|
@@ -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
|
-
| `
|
|
253
|
-
| `
|
|
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 `
|
|
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.
|
|
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.
|
|
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
|
-
###
|
|
307
|
+
### Simple includes
|
|
301
308
|
|
|
302
|
-
Use `
|
|
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
|
-
<%-
|
|
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.
|
|
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
|
-
<%-
|
|
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.
|
|
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
|
-
|
|
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
|
-
<%-
|
|
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 `
|
|
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
|
-
- `
|
|
804
|
-
- `
|
|
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.
|
|
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/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
|
|
|
@@ -451,7 +452,7 @@ export declare class ModelMix {
|
|
|
451
452
|
raw(): Promise<ModelMixResult>;
|
|
452
453
|
stream(callback: StreamCallback): Promise<ModelMixResult>;
|
|
453
454
|
|
|
454
|
-
|
|
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.
|
|
391
|
-
validateTemplateData(this.config.
|
|
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
|
-
|
|
415
|
+
assign(keyValues) {
|
|
405
416
|
validateTemplateData(keyValues);
|
|
406
|
-
|
|
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
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
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
package/skills/modelmix/SKILL.md
CHANGED
|
@@ -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/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');
|