modelmix 4.7.4 → 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
@@ -163,54 +163,54 @@ Here's a comprehensive list of available methods:
163
163
 
164
164
  | Method | Provider | Model | Price (I/O) per 1 M tokens |
165
165
  | ------------------- | ---------- | ---------------------------- | -------------------------- |
166
- | `gpt56sol()` | OpenAI | gpt-5.6-sol | [\$5.00 / \$30.00][1] |
167
- | `gpt56terra()` | OpenAI | gpt-5.6-terra | [\$2.00 / \$12.00][1] |
168
- | `gpt56luna()` | OpenAI | gpt-5.6-luna | [\$0.20 / \$1.20][1] |
169
- | `gpt55()` | OpenAI | gpt-5.5 | [\$5.00 / \$30.00][1] |
170
- | `gpt54()` | OpenAI | gpt-5.4 | [\$2.50 / \$15.00][1] |
171
- | `gpt54mini()` | OpenAI | gpt-5.4-mini | [\$0.75 / \$4.50][1] |
172
- | `gpt54nano()` | OpenAI | gpt-5.4-nano | [\$0.20 / \$1.25][1] |
173
- | `gpt53codex()` | OpenAI | gpt-5.3-codex | [\$1.25 / \$14.00][1] |
174
- | `gpt52()` | OpenAI | gpt-5.2 | [\$1.75 / \$14.00][1] |
175
- | `gpt51()` | OpenAI | gpt-5.1 | [\$1.25 / \$10.00][1] |
176
- | `gpt5mini()` | OpenAI | gpt-5-mini | [\$0.25 / \$2.00][1] |
177
- | `gpt5nano()` | OpenAI | gpt-5-nano | [\$0.05 / \$0.40][1] |
178
- | `gpt41()` | OpenAI | gpt-4.1 | [\$2.00 / \$8.00][1] |
179
- | `gpt41mini()` | OpenAI | gpt-4.1-mini | [\$0.40 / \$1.60][1] |
180
- | `gpt41nano()` | OpenAI | gpt-4.1-nano | [\$0.10 / \$0.40][1] |
181
- | `gptOss()` | Together | gpt-oss-120B | [\$0.15 / \$0.60][7] |
182
- | `fable5()` | Anthropic | claude-fable-5 | [\$10.00 / \$50.00][2] |
183
- | `opus5()` | Anthropic | claude-opus-5 | [\$5.00 / \$25.00][2] |
184
- | `opus48()` | Anthropic | claude-opus-4-8 | [\$5.00 / \$25.00][2] |
185
- | `opus47()` | Anthropic | claude-opus-4-7 | [\$5.00 / \$25.00][2] |
186
- | `opus46()` | Anthropic | claude-opus-4-6 | [\$5.00 / \$25.00][2] |
187
- | `sonnet5()` | Anthropic | claude-sonnet-5 | [\$3.00 / \$15.00][2] |
188
- | `sonnet46()` | Anthropic | claude-sonnet-4-6 | [\$3.00 / \$15.00][2] |
189
- | `haiku45()` | Anthropic | claude-haiku-4-5-20251001 | [\$1.00 / \$5.00][2] |
190
- | `gemini31pro()` | Google | gemini-3.1-pro-preview | [\$2.00 / \$12.00][3] |
191
- | `gemini36flash()` | Google | gemini-3.6-flash | [\$1.50 / \$7.50][3] |
192
- | `gemini35flash()` | Google | gemini-3.5-flash | [\$0.75 / \$4.50][3] |
193
- | `gemini35flashLite()`| Google | gemini-3.5-flash-lite | [\$0.30 / \$2.50][3] |
194
- | `gemini31flashLite()`| Google | gemini-3.1-flash-lite-preview | [\$0.25 / \$1.50][3] |
195
- | `grok45()` | Grok | grok-4.5 | [\$2.00 / \$6.00][6] |
196
- | `grok43()` | Grok | grok-4.3 | [\$1.25 / \$2.50][6] |
197
- | `grok420multiAgent()`| Grok | grok-4.20-multi-agent-0309 | [\$1.25 / \$2.50][6] |
198
- | `grok420()` | Grok | grok-4.20-0309 (†) | [\$1.25 / \$2.50][6] |
199
- | `qwen36plus()` | Fireworks/Together | qwen3p6-plus / Qwen3.6-Plus | [\$0.50 / \$3.00][10] |
200
- | `qwen37plus()` | Fireworks | models/qwen3p7-plus | [\$0.40 / \$1.60][10] |
201
- | `qwen38max()` | OpenRouter | qwen/qwen3.8-max | [\$2.00 / \$6.00][12] |
202
- | `deepseekV4Flash()` | Fireworks | models/deepseek-v4-flash | [\$0.14 / \$0.28][10] |
203
- | `deepseekV4Pro()` | Fireworks | models/deepseek-v4-pro | [\$1.74 / \$3.48][10] |
204
- | `GLM52()` | Together | zai-org/GLM-5.2 | [\$1.40 / \$4.40][7] |
205
- | `GLM51()` | Fireworks | models/glm-5p1 | [\$1.05 / \$3.50][10] |
206
- | `minimaxM3()` | MiniMax | MiniMax-M3 | [\$0.30 / \$1.20][9] |
207
- | `minimaxM27()` | MiniMax | MiniMax-M2.7 | [\$0.30 / \$1.20][9] |
208
- | `sonar()` | Perplexity | sonar | [\$1.00 / \$1.00][4] |
209
- | `sonarPro()` | Perplexity | sonar-pro | [\$3.00 / \$15.00][4] |
210
- | `hermes3()` | Lambda | Hermes-3-Llama-3.1-405B-FP8 | [\$0.80 / \$0.80][8] |
211
- | `kimiK3()` | Moonshot | kimi-k3 | [\$3.00 / \$15.00][11] |
212
- | `kimiK25()` | Together | Kimi-K2.5 | [\$0.50 / \$2.80][7] |
213
- | `kimiK26()` | Fireworks | models/kimi-k2p6 | [\$0.95 / \$4.00][10] |
166
+ | `gpt56sol()` | OpenAI | gpt-5.6-sol | [\$5.00/\$30.00][1] |
167
+ | `gpt56terra()` | OpenAI | gpt-5.6-terra | [\$2.00/\$12.00][1] |
168
+ | `gpt56luna()` | OpenAI | gpt-5.6-luna | [\$0.20/\$1.20][1] |
169
+ | `gpt55()` | OpenAI | gpt-5.5 | [\$5.00/\$30.00][1] |
170
+ | `gpt54()` | OpenAI | gpt-5.4 | [\$2.50/\$15.00][1] |
171
+ | `gpt54mini()` | OpenAI | gpt-5.4-mini | [\$0.75/\$4.50][1] |
172
+ | `gpt54nano()` | OpenAI | gpt-5.4-nano | [\$0.20/\$1.25][1] |
173
+ | `gpt53codex()` | OpenAI | gpt-5.3-codex | [\$1.25/\$14.00][1] |
174
+ | `gpt52()` | OpenAI | gpt-5.2 | [\$1.75/\$14.00][1] |
175
+ | `gpt51()` | OpenAI | gpt-5.1 | [\$1.25/\$10.00][1] |
176
+ | `gpt5mini()` | OpenAI | gpt-5-mini | [\$0.25/\$2.00][1] |
177
+ | `gpt5nano()` | OpenAI | gpt-5-nano | [\$0.05/\$0.40][1] |
178
+ | `gpt41()` | OpenAI | gpt-4.1 | [\$2.00/\$8.00][1] |
179
+ | `gpt41mini()` | OpenAI | gpt-4.1-mini | [\$0.40/\$1.60][1] |
180
+ | `gpt41nano()` | OpenAI | gpt-4.1-nano | [\$0.10/\$0.40][1] |
181
+ | `gptOss()` | Together | gpt-oss-120B | [\$0.15/\$0.60][7] |
182
+ | `fable5()` | Anthropic | claude-fable-5 | [\$10.00/\$50.00][2] |
183
+ | `opus5()` | Anthropic | claude-opus-5 | [\$5.00/\$25.00][2] |
184
+ | `opus48()` | Anthropic | claude-opus-4-8 | [\$5.00/\$25.00][2] |
185
+ | `opus47()` | Anthropic | claude-opus-4-7 | [\$5.00/\$25.00][2] |
186
+ | `opus46()` | Anthropic | claude-opus-4-6 | [\$5.00/\$25.00][2] |
187
+ | `sonnet5()` | Anthropic | claude-sonnet-5 | [\$3.00/\$15.00][2] |
188
+ | `sonnet46()` | Anthropic | claude-sonnet-4-6 | [\$3.00/\$15.00][2] |
189
+ | `haiku45()` | Anthropic | claude-haiku-4-5-20251001 | [\$1.00/\$5.00][2] |
190
+ | `gemini31pro()` | Google | gemini-3.1-pro-preview | [\$2.00/\$12.00][3] |
191
+ | `gemini36flash()` | Google | gemini-3.6-flash | [\$1.50/\$7.50][3] |
192
+ | `gemini35flash()` | Google | gemini-3.5-flash | [\$0.75/\$4.50][3] |
193
+ | `gemini35flashLite()`| Google | gemini-3.5-flash-lite | [\$0.30/\$2.50][3] |
194
+ | `gemini31flashLite()`| Google | gemini-3.1-flash-lite-preview | [\$0.25/\$1.50][3] |
195
+ | `grok45()` | Grok | grok-4.5 | [\$2.00/\$6.00][6] |
196
+ | `grok43()` | Grok | grok-4.3 | [\$1.25/\$2.50][6] |
197
+ | `grok420multiAgent()`| Grok | grok-4.20-multi-agent-0309 | [\$1.25/\$2.50][6] |
198
+ | `grok420()` | Grok | grok-4.20-0309 (†) | [\$1.25/\$2.50][6] |
199
+ | `qwen36plus()` | Fireworks | qwen3p6-plus | [\$0.50/\$3.00][10] |
200
+ | `qwen37plus()` | Fireworks | models/qwen3p7-plus | [\$0.40/\$1.60][10] |
201
+ | `qwen38max()` | OpenRouter | qwen/qwen3.8-max | [\$2.00/\$6.00][12] |
202
+ | `deepseekV4Flash()` | Fireworks | models/deepseek-v4-flash | [\$0.14/\$0.28][10] |
203
+ | `deepseekV4Pro()` | Fireworks | models/deepseek-v4-pro | [\$1.74/\$3.48][10] |
204
+ | `GLM52()` | Together | zai-org/GLM-5.2 | [\$1.40/\$4.40][7] |
205
+ | `GLM51()` | Fireworks | models/glm-5p1 | [\$1.05/\$3.50][10] |
206
+ | `minimaxM3()` | MiniMax | MiniMax-M3 | [\$0.30/\$1.20][9] |
207
+ | `minimaxM27()` | MiniMax | MiniMax-M2.7 | [\$0.30/\$1.20][9] |
208
+ | `sonar()` | Perplexity | sonar | [\$1.00/\$1.00][4] |
209
+ | `sonarPro()` | Perplexity | sonar-pro | [\$3.00/\$15.00][4] |
210
+ | `hermes3()` | Lambda | Hermes-3-Llama-3.1-405B-FP8 | [\$0.80/\$0.80][8] |
211
+ | `kimiK3()` | Moonshot | kimi-k3 | [\$3.00/\$15.00][11] |
212
+ | `kimiK25()` | Together | Kimi-K2.5 | [\$0.50/\$2.80][7] |
213
+ | `kimiK26()` | Fireworks | models/kimi-k2p6 | [\$0.95/\$4.00][10] |
214
214
 
215
215
  [1]: https://platform.openai.com/docs/pricing "Pricing | OpenAI"
216
216
  [2]: https://docs.anthropic.com/en/docs/about-claude/pricing "Pricing - Anthropic"
@@ -226,11 +226,6 @@ Here's a comprehensive list of available methods:
226
226
  [12]: https://openrouter.ai/qwen/qwen3.8-max "OpenRouter Pricing"
227
227
 
228
228
  Each method accepts optional `options`, `config`, and (for multi-provider methods) `mix` parameters to customize behavior.
229
- † `grok420()` resolves to `grok-4.20-0309-non-reasoning` by default, or `grok-4.20-0309-reasoning` when `.effort(20+)` / `-1` (or native non-`none` `reasoning_effort`) is set.
230
- For NVIDIA on DeepSeek V4 Flash/Pro, use `deepseekV4Flash({ mix: { nvidia: true } })` or `deepseekV4Pro({ mix: { nvidia: true } })`.
231
- For Together on Qwen 3.6 Plus, use `qwen36plus({ mix: { fireworks: false, together: true } })`.
232
- For OpenRouter on Qwen 3.7 Plus, use `qwen37plus({ mix: { fireworks: false, openrouter: true } })`.
233
- For OpenRouter instead of Moonshot's native API, use `kimiK3({ mix: { moonshot: false, openrouter: true } })`.
234
229
 
235
230
  ```javascript
236
231
  const result = await ModelMix.new({
@@ -244,7 +239,9 @@ const result = await ModelMix.new({
244
239
 
245
240
  ## 🔄 Templates
246
241
 
247
- ModelMix includes a simple but powerful templating system. You can write your system prompts and user messages in external `.md` files with placeholders, then use `replace` to fill them in at runtime.
242
+ ModelMix renders system prompts and user messages with [EJS](https://ejs.co/). Templates can be inline or stored in external files, and support variables, conditionals, loops, and relative includes.
243
+
244
+ Templates are executable JavaScript and must be controlled by the developer. Pass untrusted content only as template data, never as the template source.
248
245
 
249
246
  ### Core methods
250
247
 
@@ -252,35 +249,42 @@ ModelMix includes a simple but powerful templating system. You can write your sy
252
249
  | --- | --- |
253
250
  | `setSystemFromFile(path)` | Load the system prompt from a file |
254
251
  | `addTextFromFile(path)` | Load a user message from a file |
255
- | `replace({ key: value })` | Replace placeholders in all messages and the system prompt |
256
- | `replaceKeyFromFile(key, path)` | Replace a placeholder with the contents of a file |
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 |
257
255
 
258
- ### Basic example with `replace`
256
+ ### Basic example with `assign`
259
257
 
260
258
  ```javascript
261
259
  const gpt = ModelMix.new().gpt52();
262
260
 
263
- gpt.addText('Write a short story about a {animal} that lives in {place}.');
264
- gpt.replace({ '{animal}': 'cat', '{place}': 'a haunted castle' });
261
+ gpt.addText('Write a short story about a <%- animal %> that lives in <%- place %>.');
262
+ gpt.assign({ animal: 'cat', place: 'a haunted castle' });
265
263
 
266
264
  console.log(await gpt.message());
267
265
  ```
268
266
 
267
+ Use `assignKey()` when assigning a single value:
268
+
269
+ ```javascript
270
+ gpt.assignKey('animal', 'cat');
271
+ ```
272
+
269
273
  ### Loading prompts from `.md` files
270
274
 
271
275
  Instead of writing long prompts inline, keep them in separate Markdown files. This makes them easier to read, edit, and version control.
272
276
 
273
277
  **`prompts/system.md`**
274
278
  ```markdown
275
- You are {role}, an expert in {topic}.
276
- Always respond in {language}.
279
+ You are <%- role %>, an expert in <%- topic %>.
280
+ Always respond in <%- language %>.
277
281
  ```
278
282
 
279
283
  **`prompts/task.md`**
280
284
  ```markdown
281
285
  Analyze the following and provide 3 key insights:
282
286
 
283
- {content}
287
+ <%- content %>
284
288
  ```
285
289
 
286
290
  **`app.js`**
@@ -290,25 +294,48 @@ const gpt = ModelMix.new().gpt56luna();
290
294
  gpt.setSystemFromFile('./prompts/system.md');
291
295
  gpt.addTextFromFile('./prompts/task.md');
292
296
 
293
- gpt.replace({
294
- '{role}': 'a senior analyst',
295
- '{topic}': 'market trends',
296
- '{language}': 'Spanish',
297
- '{content}': 'Bitcoin surpassed $100,000 in December 2024...'
297
+ gpt.assign({
298
+ role: 'a senior analyst',
299
+ topic: 'market trends',
300
+ language: 'Spanish',
301
+ content: 'Bitcoin surpassed $100,000 in December 2024...'
298
302
  });
299
303
 
300
304
  console.log(await gpt.message());
301
305
  ```
302
306
 
303
- ### Injecting file contents into a placeholder
307
+ ### Simple includes
308
+
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
+ ```
304
329
 
305
- Use `replaceKeyFromFile` when the replacement value itself is a large text stored in a file.
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.
306
333
 
307
334
  **`prompts/summarize.md`**
308
335
  ```markdown
309
336
  Summarize the following article in 3 bullet points:
310
337
 
311
- {article}
338
+ <%- include(articleFile) %>
312
339
  ```
313
340
 
314
341
  **`app.js`**
@@ -316,28 +343,51 @@ Summarize the following article in 3 bullet points:
316
343
  const gpt = ModelMix.new().gpt5mini();
317
344
 
318
345
  gpt.addTextFromFile('./prompts/summarize.md');
319
- gpt.replaceKeyFromFile('{article}', './data/article.md');
346
+ gpt.assign({ articleFile: '../data/article.md' });
347
+
348
+ console.log(await gpt.message());
349
+ ```
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 %>');
320
363
 
321
364
  console.log(await gpt.message());
322
365
  ```
323
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
+
324
369
  ### Full template workflow
325
370
 
326
371
  Combine all methods to build reusable, file-based prompt pipelines:
327
372
 
328
373
  **`prompts/system.md`**
329
374
  ```markdown
330
- You are {role}. Follow these rules:
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
331
382
  - Be concise
332
383
  - Use examples when possible
333
- - Respond in {language}
334
384
  ```
335
385
 
336
386
  **`prompts/review.md`**
337
387
  ```markdown
338
388
  Review the following code and suggest improvements:
339
389
 
340
- {code}
390
+ <%- include('../src/utils.js') %>
341
391
  ```
342
392
 
343
393
  **`app.js`**
@@ -347,12 +397,79 @@ const gpt = ModelMix.new().gpt5mini();
347
397
  gpt.setSystemFromFile('./prompts/system.md');
348
398
  gpt.addTextFromFile('./prompts/review.md');
349
399
 
350
- gpt.replace({ '{role}': 'a senior code reviewer', '{language}': 'English' });
351
- gpt.replaceKeyFromFile('{code}', './src/utils.js');
400
+ gpt.assign({ role: 'a senior code reviewer', language: 'English' });
352
401
 
353
402
  console.log(await gpt.message());
354
403
  ```
355
404
 
405
+ ### EJS output and control flow
406
+
407
+ Use `<%- value %>` for raw prompt content and `<%= value %>` only when XML escaping is intentional. Missing variables and missing files throw immediately.
408
+
409
+ ```ejs
410
+ <% if (user.active) { %>
411
+ Review these roles:
412
+ <% user.roles.forEach(role => { %>
413
+ - <%- role %>
414
+ <% }) %>
415
+ <% } %>
416
+ ```
417
+
418
+ ### Random prompt choices
419
+
420
+ Use a `choice` block to include exactly one prompt variant. When no weights are present, every option has the same probability:
421
+
422
+ ```ejs
423
+ <% choice %>
424
+ <% option %>
425
+ Use emojis.
426
+ <% option %>
427
+ Use few emojis.
428
+ <% option %>
429
+ Do not use emojis.
430
+ <% /choice %>
431
+ ```
432
+
433
+ Add a positive weight after every `option` when the probabilities should differ:
434
+
435
+ ```ejs
436
+ <% choice %>
437
+ <% option 20 %>
438
+ Use emojis.
439
+ <% option 40 %>
440
+ Use few emojis.
441
+ <% option 40 %>
442
+ Do not use emojis.
443
+ <% /choice %>
444
+ ```
445
+
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.
447
+
448
+ ### Recursive includes
449
+
450
+ An included template can include itself to render recursive data. Always define a stopping condition:
451
+
452
+ ```ejs
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());
469
+ ```
470
+
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.
472
+
356
473
  ## 🧩 JSON Structured Output
357
474
 
358
475
  The `json` method forces the model to return a structured JSON response. You define the shape with an example object and optionally describe each field.
@@ -541,12 +658,29 @@ Every response from `raw()` now includes a `tokens` object with the following st
541
658
  ```javascript
542
659
  {
543
660
  tokens: {
544
- input: 150, // Number of tokens in the prompt/input
545
- output: 75, // Number of tokens in the completion/output
546
- total: 225, // Total tokens used (input + output)
547
- cached: 100, // Cached input tokens reported by the provider (0 when absent)
548
- cost: 0.0012, // Estimated cost in USD (null if model not in pricing table)
549
- speed: 42 // Output tokens per second (int)
661
+ input: 1200, // Total input tokens, including cache reads and writes
662
+ output: 50, // Number of output tokens
663
+ total: 1250, // Total tokens used
664
+ cached: 1024, // Input tokens read from cache
665
+ cacheWrite: 0, // Input tokens written to cache
666
+ cacheWrite5m: 0, // Anthropic writes using the 5-minute TTL
667
+ cacheWrite1h: 0, // Anthropic writes using the 1-hour TTL
668
+ uncachedInput: 176, // max(0, input - cached - cacheWrite)
669
+ cacheHitRate: 0.8533, // cached / input, rounded to 4 decimals
670
+ cacheSavings: 0.00018432, // USD saved by cache reads
671
+ cacheWritePremium: 0, // Extra USD paid to write this cache entry
672
+ breakEvenHits: 0, // Full future hits needed to recover that premium
673
+ cost: 0.00011568, // Total estimated cost in USD
674
+ costBreakdown: {
675
+ uncachedInput: 0.0000352,
676
+ cachedInput: 0.00002048,
677
+ cacheWrite: 0,
678
+ cacheWrite5m: 0,
679
+ cacheWrite1h: 0,
680
+ output: 0.00006,
681
+ total: 0.00011568
682
+ },
683
+ speed: 42 // Output tokens per second (int)
550
684
  }
551
685
  }
552
686
  ```
@@ -558,10 +692,66 @@ After calling `message()` or `json()`, use `lastRaw` to access the complete resp
558
692
  ```javascript
559
693
  const text = await model.message();
560
694
  console.log(model.lastRaw.tokens);
561
- // { input: 122, output: 86, total: 208, cached: 41, cost: 0.000319, speed: 38 }
695
+ // Same normalized token and cost structure returned by raw()
696
+ ```
697
+
698
+ `cached` aggregates cache reads reported by the provider, while `cacheWrite` aggregates cache writes. Anthropic additionally exposes `cacheWrite5m` and `cacheWrite1h` because those writes cost 1.25× and 2× the normal input rate, respectively. `cacheSavings` compares cache reads with the normal input rate, `cacheWritePremium` compares writes with that rate, and `breakEvenHits` estimates how many complete future hits recover the current write premium. For Anthropic, `input` is normalized to include uncached input, cache reads, and cache writes. Missing usage or pricing categories return `0`. The `speed` field is the generation speed measured in output tokens per second (integer).
699
+
700
+ ### GPT-5.6 prompt caching
701
+
702
+ GPT-5.6 supports implicit or explicit caching through `prompt_cache_options`. Put the explicit breakpoint at the end of the stable prefix; the provider only caches prompts with at least 1,024 tokens.
703
+
704
+ ```javascript
705
+ const model = ModelMix.new()
706
+ .gpt56luna({
707
+ options: {
708
+ prompt_cache_key: 'support-agent-v1',
709
+ prompt_cache_options: { mode: 'explicit', ttl: '30m' }
710
+ }
711
+ })
712
+ .addText(longStableInstructions, {
713
+ cache: { breakpoint: true }
714
+ })
715
+ .addText('Answer this variable request.');
716
+
717
+ const result = await model.raw();
718
+ console.log(result.tokens.cached, result.tokens.cacheWrite, result.tokens.cost);
719
+ ```
720
+
721
+ The provider-neutral `cache: { breakpoint: true }` option is accepted by `addTextFromFile()`, `addImage()`, `addImageFromUrl()`, and `addImageFromBuffer()`. Responses-native `input_text`, `input_image`, and `input_file` blocks preserve the native `prompt_cache_breakpoint` field when supplied directly through `options.messages`.
722
+
723
+ GPT-5.6 uses `prompt_cache_options.ttl`; `prompt_cache_retention` remains available for earlier OpenAI models. ModelMix rejects the incompatible control instead of silently dropping it. For GPT-5.6 requests over 272K input tokens, the cost calculation applies the documented 2× input and 1.5× output multipliers to the complete request, including cache reads and writes.
724
+
725
+ GPT-5.6 prices per 1M tokens:
726
+
727
+ | Model | Input | Cached input | Cache write | Output |
728
+ | --- | ---: | ---: | ---: | ---: |
729
+ | `gpt-5.6-sol` | $5.00 | $0.50 | $6.25 | $30.00 |
730
+ | `gpt-5.6-terra` | $2.00 | $0.20 | $2.50 | $12.00 |
731
+ | `gpt-5.6-luna` | $0.20 | $0.02 | $0.25 | $1.20 |
732
+
733
+ ### Cross-provider cache fallback
734
+
735
+ Neutral breakpoints are translated at the last moment by each provider adapter. Native request policies remain scoped to their model, so they cannot leak into a fallback request:
736
+
737
+ ```javascript
738
+ const model = ModelMix.new()
739
+ .gpt56luna({
740
+ options: {
741
+ prompt_cache_key: 'support-agent-v1',
742
+ prompt_cache_options: { mode: 'explicit', ttl: '30m' }
743
+ }
744
+ })
745
+ .haiku45({
746
+ options: {
747
+ cache_control: { type: 'ephemeral', ttl: '1h' }
748
+ }
749
+ })
750
+ .addText(longStableInstructions, { cache: { breakpoint: true } })
751
+ .addText('Answer this variable request.');
562
752
  ```
563
753
 
564
- The `cached` field is a single aggregated count of cached input tokens reported by the provider. The `cost` field is the estimated cost in USD based on the model's pricing per 1M tokens (input/output). If the model is not found in the pricing table, `cost` will be `null`. The `speed` field is the generation speed measured in output tokens per second (integer).
754
+ GPT-5.6 receives `prompt_cache_breakpoint`; Anthropic receives `cache_control`; older OpenAI models and providers without an equivalent omit the marker. When a neutral explicit breakpoint is present for Anthropic, its model-scoped `cache_control` becomes that block's policy instead of adding an automatic breakpoint after the variable suffix.
565
755
 
566
756
  ## 🐛 Enabling Debug Mode
567
757
 
@@ -675,18 +865,19 @@ new ModelMix(args = { options: {}, config: {} })
675
865
 
676
866
  - `setSystem(text)`: Sets the system prompt.
677
867
  - `setSystemFromFile(filePath)`: Sets the system prompt from a file.
678
- - `addText(text, config = { role: "user" })`: Adds a text message.
679
- - `addTextFromFile(filePath, config = { role: "user" })`: Adds a text message from a file.
680
- - `addImage(filePath, config = { role: "user" })`: Adds an image message from a file path.
681
- - `addImageFromUrl(url, config = { role: "user" })`: Adds an image message from URL.
682
- - `replace(keyValues)`: Defines placeholder replacements for messages and system prompt.
683
- - `replaceKeyFromFile(key, filePath)`: Defines a placeholder replacement with file contents as value.
868
+ - `addText(text, config = { role: "user", cache? })`: Adds a text message.
869
+ - `addTextFromFile(filePath, config = { role: "user", cache? })`: Adds a text message from a file.
870
+ - `addImage(filePath, config = { role: "user", cache? })`: Adds an image message from a file path.
871
+ - `addImageFromUrl(url, config = { role: "user", cache? })`: Adds an image message from URL.
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.
684
875
  - `message()`: Sends the message and returns the response.
685
876
  - `raw()`: Sends the message and returns the complete response data including:
686
877
  - `message`: The text response from the model
687
878
  - `think`: Reasoning/thinking content (if available)
688
879
  - `toolCalls`: Array of tool calls made by the model (if any)
689
- - `tokens`: Object with `input`, `output`, `total`, and `cached` token counts, plus `cost` (USD) and `speed` (output tokens/sec)
880
+ - `tokens`: Normalized token counts (`input`, `output`, `total`, `cached`, `cacheWrite`, `cacheWrite5m`, `cacheWrite1h`, `uncachedInput`, `cacheHitRate`), cache economics (`cacheSavings`, `cacheWritePremium`, `breakEvenHits`), plus `cost`, `costBreakdown` (USD), and `speed` (output tokens/sec)
690
881
  - `response`: The raw API response
691
882
  - `stream(callback)`: Sends the message and streams the response, invoking the callback with each streamed part.
692
883
  - `json(schemaExample, descriptions = {}, options = {})`: Forces the model to return a response in a specific JSON format.
@@ -829,4 +1020,4 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of
829
1020
 
830
1021
  The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
831
1022
 
832
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
1023
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/demo/demo.js CHANGED
@@ -6,7 +6,7 @@ const mmix = new ModelMix({
6
6
  temperature: 0.5,
7
7
  },
8
8
  config: {
9
- system: 'You are {name} from Melmac.',
9
+ system: 'You are <%- name %> from Melmac.',
10
10
  max_history: 2,
11
11
  bottleneck: { maxConcurrent: 1 },
12
12
  debug: 3,
@@ -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
- const gpt = mmix.gpt51(gptArgs).addText("Have you ever eaten a {animal}?");
30
- gpt.replace({ '{animal}': 'cat' });
29
+ const gpt = mmix.gpt51(gptArgs).addText("Have you ever eaten a <%- animal %>?");
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/prompt.md CHANGED
@@ -1,4 +1,4 @@
1
- # Writing Prompt: {story_title}
1
+ # Writing Prompt: <%- storyTitle %>
2
2
 
3
3
  ## Setting
4
4
  A small, quiet town where nothing unusual ever happens.
@@ -22,4 +22,4 @@ Write a short story (500-1000 words) that explores:
22
22
  ### Additional Elements to Consider
23
23
  - The reactions of other townspeople
24
24
  - Any changes in the town's atmosphere after the package's arrival
25
- - Your character's internal conflict between curiosity and duty
25
+ - Your character's internal conflict between curiosity and duty
package/http-client.js CHANGED
@@ -4,6 +4,19 @@ function headersToObject(headers) {
4
4
  return Object.fromEntries(headers.entries());
5
5
  }
6
6
 
7
+ function sanitizeUrl(url) {
8
+ try {
9
+ const parsed = new URL(url);
10
+ parsed.username = '';
11
+ parsed.password = '';
12
+ parsed.search = '';
13
+ parsed.hash = '';
14
+ return parsed.toString();
15
+ } catch {
16
+ return String(url).replace(/[?#].*$/, '');
17
+ }
18
+ }
19
+
7
20
  async function parseResponseBody(response) {
8
21
  try {
9
22
  return await response.json();
@@ -24,7 +37,7 @@ async function parseJsonBody(response) {
24
37
 
25
38
  async function buildHttpError(url, response) {
26
39
  const details = await parseResponseBody(response);
27
- const error = new Error(`Request to ${url} failed with status code ${response.status}`);
40
+ const error = new Error(`Request to ${sanitizeUrl(url)} failed with status code ${response.status}`);
28
41
  error.isHttpError = true;
29
42
  error.statusCode = response.status;
30
43
  error.details = details;
@@ -64,7 +77,7 @@ async function fetchStreamResponse(url, { method = 'POST', headers = {}, body }
64
77
  throw await buildHttpError(url, response);
65
78
  }
66
79
  if (!response.body) {
67
- throw new Error(`Request to ${url} did not return a readable stream`);
80
+ throw new Error(`Request to ${sanitizeUrl(url)} did not return a readable stream`);
68
81
  }
69
82
  return {
70
83
  data: Readable.fromWeb(response.body),